First pass at restoring original meta component
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,153 +13,249 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Instagram, Loader2 } from "lucide-react";
|
||||||
import { TIME_RANGES } from "@/lib/constants";
|
|
||||||
import { Play, Pause, DollarSign, BarChart3 } from "lucide-react";
|
|
||||||
|
|
||||||
// Helper functions for formatting
|
// Helper functions for formatting
|
||||||
const formatRate = (value) => {
|
const formatCurrency = (value, decimalPlaces = 2) =>
|
||||||
if (typeof value !== "number") return "0.00%";
|
new Intl.NumberFormat("en-US", {
|
||||||
return `${(value * 100).toFixed(2)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
if (typeof value !== "number") return "$0";
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: decimalPlaces,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: decimalPlaces,
|
||||||
}).format(value);
|
}).format(value || 0);
|
||||||
|
|
||||||
|
const formatNumber = (value, decimalPlaces = 0) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: decimalPlaces,
|
||||||
|
maximumFractionDigits: decimalPlaces,
|
||||||
|
}).format(value || 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading skeleton component
|
const formatPercent = (value, decimalPlaces = 2) =>
|
||||||
const TableSkeleton = () => (
|
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
const summaryCard = (label, value, options = {}) => {
|
||||||
<div
|
const {
|
||||||
key={i}
|
isMonetary = false,
|
||||||
className="h-16 bg-gray-100 dark:bg-gray-800 animate-pulse rounded"
|
isPercentage = false,
|
||||||
/>
|
decimalPlaces = 0,
|
||||||
))}
|
} = options;
|
||||||
|
|
||||||
|
let displayValue;
|
||||||
|
if (isMonetary) {
|
||||||
|
displayValue = formatCurrency(value, decimalPlaces);
|
||||||
|
} else if (isPercentage) {
|
||||||
|
displayValue = formatPercent(value, decimalPlaces);
|
||||||
|
} else {
|
||||||
|
displayValue = formatNumber(value, decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Error alert component
|
|
||||||
const ErrorAlert = ({ description }) => (
|
|
||||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// MetricCell component for displaying campaign metrics
|
|
||||||
const MetricCell = ({
|
const MetricCell = ({
|
||||||
value,
|
value,
|
||||||
count,
|
label,
|
||||||
|
sublabel,
|
||||||
isMonetary = false,
|
isMonetary = false,
|
||||||
label = "",
|
isPercentage = false,
|
||||||
tooltipText = "",
|
decimalPlaces = 0,
|
||||||
}) => {
|
}) => (
|
||||||
return (
|
<td className="p-2 text-center align-top">
|
||||||
<TooltipProvider>
|
<div className="text-blue-600 dark:text-blue-400 text-left font-semibold">
|
||||||
<Tooltip>
|
{isMonetary
|
||||||
<TooltipTrigger asChild>
|
? formatCurrency(value, decimalPlaces)
|
||||||
<td className="p-2 text-center">
|
: isPercentage
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
? formatPercent(value, decimalPlaces)
|
||||||
{isMonetary ? formatCurrency(value) : formatRate(value)}
|
: formatNumber(value, decimalPlaces)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-600 dark:text-gray-400 text-sm">
|
{label && (
|
||||||
{count?.toLocaleString() || 0} {label}
|
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{sublabel && (
|
||||||
|
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{tooltipText}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getActionValue = (campaign, actionType) => {
|
||||||
|
if (actionType === "impressions" || actionType === "reach") {
|
||||||
|
return campaign.metrics[actionType] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = campaign.metrics.actions;
|
||||||
|
if (Array.isArray(actions)) {
|
||||||
|
const action = actions.find((a) => a.action_type === actionType);
|
||||||
|
return action ? parseInt(action.value) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MetaCampaigns = ({ className }) => {
|
const CampaignName = ({ name }) => {
|
||||||
const [campaigns, setCampaigns] = useState([]);
|
if (name.startsWith("Instagram post: ")) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Instagram className="w-4 h-4" />
|
||||||
|
<span>{name.replace("Instagram post: ", "")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{name}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetaCampaigns = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
const [sortConfig, setSortConfig] = useState({
|
const [timeframe, setTimeframe] = useState("30");
|
||||||
key: "spend",
|
const [summaryMetrics, setSummaryMetrics] = useState(null);
|
||||||
direction: "desc",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = (key) => {
|
const computeDateRange = (timeframe) => {
|
||||||
setSortConfig((prev) => ({
|
// Create date in Eastern Time
|
||||||
key,
|
const now = new Date();
|
||||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
const easternTime = new Date(
|
||||||
}));
|
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCampaigns = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const today = DateTime.now();
|
|
||||||
const startDate = today.minus({ days: 7 }).toISODate();
|
|
||||||
const endDate = today.toISODate();
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/meta/campaigns?since=${startDate}&until=${endDate}`
|
|
||||||
);
|
);
|
||||||
|
easternTime.setHours(0, 0, 0, 0); // Set to start of day
|
||||||
|
|
||||||
if (!response.ok) {
|
let sinceDate, untilDate;
|
||||||
throw new Error(`Failed to fetch campaigns: ${response.status}`);
|
|
||||||
|
if (timeframe === "today") {
|
||||||
|
// For today, both dates should be the current date in Eastern Time
|
||||||
|
sinceDate = untilDate = new Date(easternTime);
|
||||||
|
} else {
|
||||||
|
// For other periods, calculate the date range
|
||||||
|
untilDate = new Date(easternTime);
|
||||||
|
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
|
||||||
|
|
||||||
|
sinceDate = new Date(untilDate);
|
||||||
|
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
return {
|
||||||
setCampaigns(data || []);
|
since: sinceDate.toISOString().split("T")[0],
|
||||||
setError(null);
|
until: untilDate.toISOString().split("T")[0],
|
||||||
} catch (err) {
|
};
|
||||||
console.error("Error fetching campaigns:", err);
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCampaigns();
|
const fetchMetaAdsData = async () => {
|
||||||
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
|
try {
|
||||||
return () => clearInterval(interval);
|
setLoading(true);
|
||||||
}, [selectedTimeRange]);
|
setError(null);
|
||||||
|
|
||||||
// Sort campaigns
|
const { since, until } = computeDateRange(timeframe);
|
||||||
const sortedCampaigns = [...campaigns].sort((a, b) => {
|
|
||||||
const direction = sortConfig.direction === "desc" ? -1 : 1;
|
|
||||||
const insights_a = a.insights?.data?.[0] || {};
|
|
||||||
const insights_b = b.insights?.data?.[0] || {};
|
|
||||||
|
|
||||||
switch (sortConfig.key) {
|
const [campaignData, accountInsights] = await Promise.all([
|
||||||
case "spend":
|
fetch(`/api/meta/campaigns?since=${since}&until=${until}`),
|
||||||
return direction * ((insights_a.spend || 0) - (insights_b.spend || 0));
|
fetch(`/api/meta/account-insights?since=${since}&until=${until}`)
|
||||||
case "impressions":
|
]);
|
||||||
return direction * ((insights_a.impressions || 0) - (insights_b.impressions || 0));
|
|
||||||
case "clicks":
|
const [campaignsJson, accountJson] = await Promise.all([
|
||||||
return direction * ((insights_a.clicks || 0) - (insights_b.clicks || 0));
|
campaignData.json(),
|
||||||
case "ctr":
|
accountInsights.json()
|
||||||
return direction * ((insights_a.ctr || 0) - (insights_b.ctr || 0));
|
]);
|
||||||
case "cpc":
|
|
||||||
return direction * ((insights_a.cpc || 0) - (insights_b.cpc || 0));
|
// Process campaigns to match the expected format
|
||||||
default:
|
const processedCampaigns = campaignsJson.map(campaign => {
|
||||||
return 0;
|
const insights = campaign.insights?.data?.[0] || {};
|
||||||
|
const frequency = parseFloat(insights.frequency) || 0;
|
||||||
|
return {
|
||||||
|
id: campaign.id,
|
||||||
|
name: campaign.name,
|
||||||
|
objective: campaign.objective,
|
||||||
|
status: campaign.status,
|
||||||
|
budget: campaign.daily_budget / 100,
|
||||||
|
budgetType: 'daily',
|
||||||
|
metrics: {
|
||||||
|
spend: parseFloat(insights.spend) || 0,
|
||||||
|
impressions: parseInt(insights.impressions) || 0,
|
||||||
|
reach: parseInt(insights.reach) || 0,
|
||||||
|
clicks: parseInt(insights.clicks) || 0,
|
||||||
|
ctr: parseFloat(insights.ctr) || 0,
|
||||||
|
cpc: parseFloat(insights.cpc) || 0,
|
||||||
|
cpm: parseFloat(insights.cpm) || 0,
|
||||||
|
frequency,
|
||||||
|
totalPurchases: parseInt(insights.purchase) || 0,
|
||||||
|
purchaseValue: parseFloat(insights.purchase_value) || 0,
|
||||||
|
totalPostEngagements: parseInt(insights.post_engagement) || 0,
|
||||||
|
costPerResult: parseFloat(insights.cost_per_action_type?.find(c => c.action_type === 'purchase')?.value) || 0,
|
||||||
|
actions: insights.actions || []
|
||||||
}
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
|
||||||
|
setCampaigns(activeCampaigns);
|
||||||
|
|
||||||
|
if (activeCampaigns.length > 0) {
|
||||||
|
const totalSpend = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.spend, 0);
|
||||||
|
const totalImpressions = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.impressions, 0);
|
||||||
|
const totalReach = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.reach, 0);
|
||||||
|
const totalPurchases = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPurchases, 0);
|
||||||
|
const totalPurchaseValue = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.purchaseValue, 0);
|
||||||
|
const totalLinkClicks = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.clicks, 0);
|
||||||
|
const totalPostEngagements = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPostEngagements, 0);
|
||||||
|
|
||||||
|
const numCampaigns = activeCampaigns.length;
|
||||||
|
const avgFrequency = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.frequency, 0) / numCampaigns;
|
||||||
|
const avgCpm = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpm, 0) / numCampaigns;
|
||||||
|
const avgCtr = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.ctr, 0) / numCampaigns;
|
||||||
|
const avgCpc = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpc, 0) / numCampaigns;
|
||||||
|
|
||||||
|
setSummaryMetrics({
|
||||||
|
totalSpend,
|
||||||
|
totalPurchaseValue,
|
||||||
|
totalLinkClicks,
|
||||||
|
totalImpressions,
|
||||||
|
totalReach,
|
||||||
|
totalPurchases,
|
||||||
|
avgFrequency,
|
||||||
|
avgCpm,
|
||||||
|
avgCtr,
|
||||||
|
avgCpc,
|
||||||
|
totalPostEngagements,
|
||||||
|
totalCampaigns: numCampaigns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Meta Ads fetch error:", err);
|
||||||
|
setError(`Failed to fetch Meta Ads data: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMetaAdsData();
|
||||||
|
}, [timeframe]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full bg-white dark:bg-gray-900">
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
<CardHeader>
|
<CardContent className="h-[400px] flex items-center justify-center">
|
||||||
<div className="h-6 w-48 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" />
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||||
</CardHeader>
|
</CardContent>
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px]">
|
</Card>
|
||||||
<TableSkeleton />
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-red-500 dark:text-red-400">{error}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -168,155 +263,182 @@ const MetaCampaigns = ({ className }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full bg-white dark:bg-gray-900">
|
<Card className="h-full bg-white dark:bg-gray-900">
|
||||||
{error && <ErrorAlert description={error} />}
|
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Meta Ad Campaigns
|
Meta Ads Performance
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex gap-2">
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectValue placeholder="Select range" />
|
||||||
<SelectValue placeholder="Select time range" />
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIME_RANGES.map((option) => (
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
{option.label}
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
))}
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3 mt-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Active Campaigns",
|
||||||
|
value: summaryMetrics?.totalCampaigns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Spend",
|
||||||
|
value: summaryMetrics?.totalSpend,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 0 },
|
||||||
|
},
|
||||||
|
{ label: "Total Reach", value: summaryMetrics?.totalReach },
|
||||||
|
{
|
||||||
|
label: "Total Impressions",
|
||||||
|
value: summaryMetrics?.totalImpressions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg Frequency",
|
||||||
|
value: summaryMetrics?.avgFrequency,
|
||||||
|
options: { decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Engagements",
|
||||||
|
value: summaryMetrics?.totalPostEngagements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CPM",
|
||||||
|
value: summaryMetrics?.avgCpm,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CTR",
|
||||||
|
value: summaryMetrics?.avgCtr,
|
||||||
|
options: { isPercentage: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CPC",
|
||||||
|
value: summaryMetrics?.avgCpc,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Link Clicks",
|
||||||
|
value: summaryMetrics?.totalLinkClicks,
|
||||||
|
},
|
||||||
|
{ label: "Total Purchases", value: summaryMetrics?.totalPurchases },
|
||||||
|
{
|
||||||
|
label: "Purchase Value",
|
||||||
|
value: summaryMetrics?.totalPurchaseValue,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 0 },
|
||||||
|
},
|
||||||
|
].map((card) => (
|
||||||
|
<div key={card.label}>
|
||||||
|
{summaryCard(card.label, card.value, card.options)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px] mb-4">
|
|
||||||
<table className="w-full">
|
<CardContent className="p-4">
|
||||||
|
<div className="grid overflow-x-auto">
|
||||||
|
<div className="overflow-y-auto max-h-[400px]">
|
||||||
|
<table className="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||||
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
Campaign
|
Campaign
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "spend" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("spend")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Spend
|
Spend
|
||||||
</Button>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
<Button
|
Reach
|
||||||
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
</th>
|
||||||
onClick={() => handleSort("impressions")}
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Impressions
|
Impressions
|
||||||
</Button>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
<Button
|
CPM
|
||||||
variant={sortConfig.key === "clicks" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("clicks")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Clicks
|
|
||||||
</Button>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("ctr")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
CTR
|
CTR
|
||||||
</Button>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
<Button
|
Results
|
||||||
variant={sortConfig.key === "cpc" ? "default" : "ghost"}
|
</th>
|
||||||
onClick={() => handleSort("cpc")}
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
className="w-full justify-center h-8"
|
Value
|
||||||
>
|
</th>
|
||||||
CPC
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
</Button>
|
Engagements
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
{sortedCampaigns.map((campaign) => {
|
{campaigns.map((campaign) => (
|
||||||
const insights = campaign.insights?.data?.[0] || {};
|
|
||||||
return (
|
|
||||||
<tr
|
<tr
|
||||||
key={campaign.id}
|
key={campaign.id}
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<td className="p-2 align-top">
|
<td className="p-2 align-top">
|
||||||
<div className="flex items-center gap-2">
|
<div className="font-medium text-gray-900 dark:text-gray-100 break-words min-w-[200px] max-w-[300px]">
|
||||||
{campaign.status === 'ACTIVE' ? (
|
<CampaignName name={campaign.name} />
|
||||||
<Play className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Pause className="h-4 w-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{campaign.name}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{campaign.objective}
|
{campaign.objective}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
Budget: {formatCurrency(campaign.daily_budget / 100)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
|
||||||
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<p className="font-medium">{campaign.name}</p>
|
|
||||||
<p>{campaign.objective}</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Daily Budget: {formatCurrency(campaign.daily_budget / 100)}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={insights.spend}
|
value={campaign.metrics.spend}
|
||||||
isMonetary={true}
|
isMonetary
|
||||||
tooltipText="Total amount spent"
|
decimalPlaces={2}
|
||||||
|
sublabel={
|
||||||
|
campaign.budget
|
||||||
|
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
|
||||||
|
: "Budget: Ad set"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MetricCell value={campaign.metrics.reach} />
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={insights.impressions}
|
value={campaign.metrics.impressions}
|
||||||
count={insights.impressions}
|
label={`${campaign.metrics.frequency.toFixed(2)}x freq`}
|
||||||
label="views"
|
|
||||||
tooltipText="Number of times your ads were viewed"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={insights.clicks}
|
value={campaign.metrics.cpm}
|
||||||
count={insights.clicks}
|
isMonetary
|
||||||
label="clicks"
|
decimalPlaces={2}
|
||||||
tooltipText="Number of clicks on your ads"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={insights.ctr}
|
value={campaign.metrics.ctr}
|
||||||
tooltipText="Click-through rate (Clicks / Impressions)"
|
isPercentage
|
||||||
|
decimalPlaces={2}
|
||||||
|
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={insights.cpc}
|
value={getActionValue(campaign, campaign.objectiveActionType)}
|
||||||
isMonetary={true}
|
label={campaign.objective}
|
||||||
tooltipText="Average cost per click"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.purchaseValue}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
sublabel={`${formatCurrency(campaign.metrics.costPerResult)}/result`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell value={campaign.metrics.totalPostEngagements} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
448
examples DO NOT USE OR EDIT/EXAMPLE ONLY MetaAdsOverview.jsx
Normal file
448
examples DO NOT USE OR EDIT/EXAMPLE ONLY MetaAdsOverview.jsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Instagram, Loader2 } from "lucide-react";
|
||||||
|
import { metaAdsService } from "@/services/metaAdsService";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
const formatCurrency = (value, decimalPlaces = 2) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: decimalPlaces,
|
||||||
|
maximumFractionDigits: decimalPlaces,
|
||||||
|
}).format(value || 0);
|
||||||
|
|
||||||
|
const formatNumber = (value, decimalPlaces = 0) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: decimalPlaces,
|
||||||
|
maximumFractionDigits: decimalPlaces,
|
||||||
|
}).format(value || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value, decimalPlaces = 2) =>
|
||||||
|
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||||
|
|
||||||
|
const summaryCard = (label, value, options = {}) => {
|
||||||
|
const {
|
||||||
|
isMonetary = false,
|
||||||
|
isPercentage = false,
|
||||||
|
decimalPlaces = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let displayValue;
|
||||||
|
if (isMonetary) {
|
||||||
|
displayValue = formatCurrency(value, decimalPlaces);
|
||||||
|
} else if (isPercentage) {
|
||||||
|
displayValue = formatPercent(value, decimalPlaces);
|
||||||
|
} else {
|
||||||
|
displayValue = formatNumber(value, decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricCell = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
isMonetary = false,
|
||||||
|
isPercentage = false,
|
||||||
|
decimalPlaces = 0,
|
||||||
|
}) => (
|
||||||
|
<td className="p-2 text-center align-top">
|
||||||
|
<div className="text-blue-600 dark:text-blue-400 text-left font-semibold">
|
||||||
|
{isMonetary
|
||||||
|
? formatCurrency(value, decimalPlaces)
|
||||||
|
: isPercentage
|
||||||
|
? formatPercent(value, decimalPlaces)
|
||||||
|
: formatNumber(value, decimalPlaces)}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sublabel && (
|
||||||
|
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getActionValue = (campaign, actionType) => {
|
||||||
|
if (actionType === "impressions" || actionType === "reach") {
|
||||||
|
return campaign.metrics[actionType] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = campaign.metrics.actions;
|
||||||
|
if (Array.isArray(actions)) {
|
||||||
|
const action = actions.find((a) => a.action_type === actionType);
|
||||||
|
return action ? parseInt(action.value) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CampaignName = ({ name }) => {
|
||||||
|
if (name.startsWith("Instagram post: ")) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Instagram className="w-4 h-4" />
|
||||||
|
<span>{name.replace("Instagram post: ", "")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{name}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetaAdsOverview = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
|
const [timeframe, setTimeframe] = useState("30");
|
||||||
|
const [summaryMetrics, setSummaryMetrics] = useState(null);
|
||||||
|
const computeDateRange = (timeframe) => {
|
||||||
|
// Create date in Eastern Time
|
||||||
|
const now = new Date();
|
||||||
|
const easternTime = new Date(
|
||||||
|
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||||
|
);
|
||||||
|
easternTime.setHours(0, 0, 0, 0); // Set to start of day
|
||||||
|
|
||||||
|
let sinceDate, untilDate;
|
||||||
|
|
||||||
|
if (timeframe === "today") {
|
||||||
|
// For today, both dates should be the current date in Eastern Time
|
||||||
|
sinceDate = untilDate = new Date(easternTime);
|
||||||
|
} else {
|
||||||
|
// For other periods, calculate the date range
|
||||||
|
untilDate = new Date(easternTime);
|
||||||
|
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
|
||||||
|
|
||||||
|
sinceDate = new Date(untilDate);
|
||||||
|
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
since: sinceDate.toISOString().split("T")[0],
|
||||||
|
until: untilDate.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetaAdsData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const { since, until } = computeDateRange(timeframe);
|
||||||
|
|
||||||
|
const [campaignData, accountInsights] = await Promise.all([
|
||||||
|
metaAdsService.getCampaigns({ since, until }),
|
||||||
|
metaAdsService.getAccountInsights({ since, until }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeCampaigns = campaignData.filter((c) => c.metrics.spend > 0);
|
||||||
|
setCampaigns(activeCampaigns);
|
||||||
|
|
||||||
|
const totalReach = parseInt(accountInsights.reach || 0);
|
||||||
|
|
||||||
|
if (activeCampaigns.length > 0) {
|
||||||
|
const totalSpend = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalImpressions = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.impressions,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalPurchases = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.totalPurchases,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalPurchaseValue = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.purchaseValue,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalLinkClicks = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + (camp.metrics.clicks || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalPostEngagements = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + (camp.metrics.totalPostEngagements || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalFrequency = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.frequency,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalCpm = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.cpm,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalCtr = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.ctr,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalCpc = activeCampaigns.reduce(
|
||||||
|
(sum, camp) => sum + camp.metrics.cpc,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const numCampaigns = activeCampaigns.length;
|
||||||
|
|
||||||
|
setSummaryMetrics({
|
||||||
|
totalSpend,
|
||||||
|
totalPurchaseValue,
|
||||||
|
totalLinkClicks,
|
||||||
|
totalImpressions,
|
||||||
|
totalReach,
|
||||||
|
totalPurchases,
|
||||||
|
avgFrequency: totalFrequency / numCampaigns,
|
||||||
|
avgCpm: totalCpm / numCampaigns,
|
||||||
|
avgCtr: totalCtr / numCampaigns,
|
||||||
|
avgCpc: totalCpc / numCampaigns,
|
||||||
|
totalPostEngagements,
|
||||||
|
totalCampaigns: numCampaigns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Meta Ads fetch error:", err);
|
||||||
|
setError(`Failed to fetch Meta Ads data: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMetaAdsData();
|
||||||
|
}, [timeframe]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="h-[400px] flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-red-500 dark:text-red-400">{error}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full bg-white dark:bg-gray-900">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Meta Ads Performance
|
||||||
|
</CardTitle>
|
||||||
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
|
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||||
|
<SelectValue placeholder="Select range" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="today">Today</SelectItem>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3 mt-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Active Campaigns",
|
||||||
|
value: summaryMetrics?.totalCampaigns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Spend",
|
||||||
|
value: summaryMetrics?.totalSpend,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 0 },
|
||||||
|
},
|
||||||
|
{ label: "Total Reach", value: summaryMetrics?.totalReach },
|
||||||
|
{
|
||||||
|
label: "Total Impressions",
|
||||||
|
value: summaryMetrics?.totalImpressions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg Frequency",
|
||||||
|
value: summaryMetrics?.avgFrequency,
|
||||||
|
options: { decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Engagements",
|
||||||
|
value: summaryMetrics?.totalPostEngagements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CPM",
|
||||||
|
value: summaryMetrics?.avgCpm,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CTR",
|
||||||
|
value: summaryMetrics?.avgCtr,
|
||||||
|
options: { isPercentage: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg CPC",
|
||||||
|
value: summaryMetrics?.avgCpc,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Link Clicks",
|
||||||
|
value: summaryMetrics?.totalLinkClicks,
|
||||||
|
},
|
||||||
|
{ label: "Total Purchases", value: summaryMetrics?.totalPurchases },
|
||||||
|
{
|
||||||
|
label: "Purchase Value",
|
||||||
|
value: summaryMetrics?.totalPurchaseValue,
|
||||||
|
options: { isMonetary: true, decimalPlaces: 0 },
|
||||||
|
},
|
||||||
|
].map((card) => (
|
||||||
|
<div key={card.label}>
|
||||||
|
{summaryCard(card.label, card.value, card.options)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="grid overflow-x-auto">
|
||||||
|
<div className="overflow-y-auto max-h-[400px]">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Campaign
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Spend
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Reach
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Impressions
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
CPM
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
CTR
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Results
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
Engagements
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{campaigns.map((campaign) => (
|
||||||
|
<tr
|
||||||
|
key={campaign.id}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-2 align-top">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100 break-words min-w-[200px] max-w-[300px]">
|
||||||
|
<CampaignName name={campaign.name} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{campaign.objective}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.spend}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
sublabel={
|
||||||
|
campaign.budget
|
||||||
|
? `${formatCurrency(campaign.budget, 0)}/${
|
||||||
|
campaign.budgetType
|
||||||
|
}`
|
||||||
|
: "Budget: Ad set"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell value={campaign.metrics.reach} />
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.impressions}
|
||||||
|
label={`${campaign.metrics.frequency.toFixed(2)}x freq`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.cpm}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.ctr}
|
||||||
|
isPercentage
|
||||||
|
decimalPlaces={2}
|
||||||
|
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={getActionValue(
|
||||||
|
campaign,
|
||||||
|
campaign.objectiveActionType
|
||||||
|
)}
|
||||||
|
label={campaign.objective}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell
|
||||||
|
value={campaign.metrics.purchaseValue}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
sublabel={`${formatCurrency(
|
||||||
|
campaign.metrics.costPerResult
|
||||||
|
)}/result`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCell value={campaign.metrics.totalPostEngagements} />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetaAdsOverview;
|
||||||
Reference in New Issue
Block a user