diff --git a/dashboard/src/components/dashboard/MetaCampaigns.jsx b/dashboard/src/components/dashboard/MetaCampaigns.jsx index 3b7861c..6ae7841 100644 --- a/dashboard/src/components/dashboard/MetaCampaigns.jsx +++ b/dashboard/src/components/dashboard/MetaCampaigns.jsx @@ -6,7 +6,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DateTime } from "luxon"; import { Select, SelectContent, @@ -14,153 +13,249 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { TIME_RANGES } from "@/lib/constants"; -import { Play, Pause, DollarSign, BarChart3 } from "lucide-react"; +import { Instagram, Loader2 } from "lucide-react"; // Helper functions for formatting -const formatRate = (value) => { - if (typeof value !== "number") return "0.00%"; - return `${(value * 100).toFixed(2)}%`; -}; - -const formatCurrency = (value) => { - if (typeof value !== "number") return "$0"; - return new Intl.NumberFormat("en-US", { +const formatCurrency = (value, decimalPlaces = 2) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); + 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); }; -// Loading skeleton component -const TableSkeleton = () => ( -
- {[...Array(5)].map((_, i) => ( -
- ))} -
-); +const formatPercent = (value, decimalPlaces = 2) => + `${(value || 0).toFixed(decimalPlaces)}%`; -// Error alert component -const ErrorAlert = ({ description }) => ( -
- {description} -
-); +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); + } -// MetricCell component for displaying campaign metrics -const MetricCell = ({ - value, - count, - isMonetary = false, - label = "", - tooltipText = "", -}) => { return ( - - - - -
- {isMonetary ? formatCurrency(value) : formatRate(value)} -
-
- {count?.toLocaleString() || 0} {label} -
- -
- - {tooltipText} - -
-
+
+
{label}
+
+ {displayValue} +
+
); }; -const MetaCampaigns = ({ className }) => { - const [campaigns, setCampaigns] = useState([]); - const [isLoading, setIsLoading] = useState(true); +const MetricCell = ({ + value, + label, + sublabel, + isMonetary = false, + isPercentage = false, + decimalPlaces = 0, +}) => ( + +
+ {isMonetary + ? formatCurrency(value, decimalPlaces) + : isPercentage + ? formatPercent(value, decimalPlaces) + : formatNumber(value, decimalPlaces)} +
+ {label && ( +
+ {label} +
+ )} + {sublabel && ( +
+ {sublabel} +
+ )} + +); + +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 ( +
+ + {name.replace("Instagram post: ", "")} +
+ ); + } + return {name}; +}; + +const MetaCampaigns = () => { + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedTimeRange, setSelectedTimeRange] = useState("last7days"); - const [sortConfig, setSortConfig] = useState({ - key: "spend", - direction: "desc", - }); + const [campaigns, setCampaigns] = useState([]); + const [timeframe, setTimeframe] = useState("30"); + const [summaryMetrics, setSummaryMetrics] = useState(null); - const handleSort = (key) => { - setSortConfig((prev) => ({ - key, - direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", - })); - }; + 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 - const fetchCampaigns = async () => { - try { - setIsLoading(true); - const today = DateTime.now(); - const startDate = today.minus({ days: 7 }).toISODate(); - const endDate = today.toISODate(); + let sinceDate, untilDate; - const response = await fetch( - `/api/meta/campaigns?since=${startDate}&until=${endDate}` - ); - - if (!response.ok) { - throw new Error(`Failed to fetch campaigns: ${response.status}`); - } - - const data = await response.json(); - setCampaigns(data || []); - setError(null); - } catch (err) { - console.error("Error fetching campaigns:", err); - setError(err.message); - } finally { - setIsLoading(false); + 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(() => { - fetchCampaigns(); - const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes - return () => clearInterval(interval); - }, [selectedTimeRange]); + const fetchMetaAdsData = async () => { + try { + setLoading(true); + setError(null); - // Sort campaigns - 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) { - case "spend": - return direction * ((insights_a.spend || 0) - (insights_b.spend || 0)); - case "impressions": - return direction * ((insights_a.impressions || 0) - (insights_b.impressions || 0)); - case "clicks": - return direction * ((insights_a.clicks || 0) - (insights_b.clicks || 0)); - case "ctr": - return direction * ((insights_a.ctr || 0) - (insights_b.ctr || 0)); - case "cpc": - return direction * ((insights_a.cpc || 0) - (insights_b.cpc || 0)); - default: - return 0; - } - }); + const { since, until } = computeDateRange(timeframe); - if (isLoading) { + const [campaignData, accountInsights] = await Promise.all([ + fetch(`/api/meta/campaigns?since=${since}&until=${until}`), + fetch(`/api/meta/account-insights?since=${since}&until=${until}`) + ]); + + const [campaignsJson, accountJson] = await Promise.all([ + campaignData.json(), + accountInsights.json() + ]); + + // Process campaigns to match the expected format + const processedCampaigns = campaignsJson.map(campaign => { + 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 || [] + } + }; + }); + + 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 ( - - -
- - - + + + + + + ); + } + + if (error) { + return ( + + +
{error}
); @@ -168,155 +263,182 @@ const MetaCampaigns = ({ className }) => { return ( - {error && } -
+
- Meta Ad Campaigns + Meta Ads Performance -
- -
+ +
+
+ {[ + { + 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) => ( +
+ {summaryCard(card.label, card.value, card.options)} +
+ ))}
- - - - - - - - - - - - - - {sortedCampaigns.map((campaign) => { - const insights = campaign.insights?.data?.[0] || {}; - return ( - - - - - - - -

{campaign.name}

-

{campaign.objective}

-

- Daily Budget: {formatCurrency(campaign.daily_budget / 100)} -

-
- - - - - - - + + +
+
+
- Campaign - - - - - - - - - - -
-
- {campaign.status === 'ACTIVE' ? ( - - ) : ( - - )} -
- {campaign.name} -
-
-
- {campaign.objective} -
-
- Budget: {formatCurrency(campaign.daily_budget / 100)} -
-
+ + + + + + + + + + + - ); - })} - -
+ Campaign + + Spend + + Reach + + Impressions + + CPM + + CTR + + Results + + Value + + Engagements +
+ + + {campaigns.map((campaign) => ( + + +
+ +
+
+ {campaign.objective} +
+ + + + + + + + + + + + + + + + + + + ))} + + +
+
); diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY MetaAdsOverview.jsx b/examples DO NOT USE OR EDIT/EXAMPLE ONLY MetaAdsOverview.jsx new file mode 100644 index 0000000..870c1d0 --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY MetaAdsOverview.jsx @@ -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 ( +
+
{label}
+
+ {displayValue} +
+
+ ); +}; + +const MetricCell = ({ + value, + label, + sublabel, + isMonetary = false, + isPercentage = false, + decimalPlaces = 0, +}) => ( + +
+ {isMonetary + ? formatCurrency(value, decimalPlaces) + : isPercentage + ? formatPercent(value, decimalPlaces) + : formatNumber(value, decimalPlaces)} +
+ {label && ( +
+ {label} +
+ )} + {sublabel && ( +
+ {sublabel} +
+ )} + +); + +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 ( +
+ + {name.replace("Instagram post: ", "")} +
+ ); + } + return {name}; +}; + +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 ( + + + + + + ); + } + + if (error) { + return ( + + +
{error}
+
+
+ ); + } + + return ( + + +
+ + Meta Ads Performance + + +
+
+ {[ + { + 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) => ( +
+ {summaryCard(card.label, card.value, card.options)} +
+ ))} +
+
+ + +
+
+ + + + + + + + + + + + + + + + {campaigns.map((campaign) => ( + + + + + + + + + + + + + + + + + + + + ))} + +
+ Campaign + + Spend + + Reach + + Impressions + + CPM + + CTR + + Results + + Value + + Engagements +
+
+ +
+
+ {campaign.objective} +
+
+
+
+
+
+ ); +}; + +export default MetaAdsOverview;