First pass at restoring original meta component

This commit is contained in:
2024-12-27 14:18:08 -05:00
parent e7f7aec93b
commit 43db209fa6
2 changed files with 838 additions and 268 deletions

View File

@@ -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>
); );

View 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;