From 0f4bbed49a9baf3c46be86742c10bd95aae78db2 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 27 Dec 2024 14:38:23 -0500 Subject: [PATCH] Add sorting and fix remaining data processing, style cards --- .../components/dashboard/MetaCampaigns.jsx | 366 ++++++++++++++---- .../EXAMPLE ONLY metaAdsService.js | 176 +++++++++ 2 files changed, 468 insertions(+), 74 deletions(-) create mode 100644 examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js diff --git a/dashboard/src/components/dashboard/MetaCampaigns.jsx b/dashboard/src/components/dashboard/MetaCampaigns.jsx index 6ae7841..2e6ce10 100644 --- a/dashboard/src/components/dashboard/MetaCampaigns.jsx +++ b/dashboard/src/components/dashboard/MetaCampaigns.jsx @@ -13,7 +13,21 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Instagram, Loader2 } from "lucide-react"; +import { + Instagram, + Loader2, + Users, + DollarSign, + Eye, + Repeat, + MousePointer, + BarChart, + Target, + ShoppingCart, + MessageCircle, + Hash, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; // Helper functions for formatting const formatCurrency = (value, decimalPlaces = 2) => @@ -39,6 +53,8 @@ const summaryCard = (label, value, options = {}) => { isMonetary = false, isPercentage = false, decimalPlaces = 0, + icon: Icon, + iconColor, } = options; let displayValue; @@ -51,12 +67,19 @@ const summaryCard = (label, value, options = {}) => { } return ( -
-
{label}
-
- {displayValue} -
-
+ + +
+
+

{label}

+

{displayValue}

+
+ {Icon && ( + + )} +
+
+
); }; @@ -69,7 +92,7 @@ const MetricCell = ({ decimalPlaces = 0, }) => ( -
+
{isMonetary ? formatCurrency(value, decimalPlaces) : isPercentage @@ -77,12 +100,12 @@ const MetricCell = ({ : formatNumber(value, decimalPlaces)}
{label && ( -
+
{label}
)} {sublabel && ( -
+
{sublabel}
)} @@ -115,12 +138,135 @@ const CampaignName = ({ name }) => { return {name}; }; +const getObjectiveAction = (campaignObjective) => { + const objectiveMap = { + OUTCOME_AWARENESS: { action_type: "impressions", label: "Impressions" }, + OUTCOME_ENGAGEMENT: { action_type: "post_engagement", label: "Post Engagements" }, + OUTCOME_TRAFFIC: { action_type: "link_click", label: "Link Clicks" }, + OUTCOME_LEADS: { action_type: "lead", label: "Leads" }, + OUTCOME_SALES: { action_type: "purchase", label: "Purchases" }, + MESSAGES: { action_type: "messages", label: "Messages" }, + }; + + return objectiveMap[campaignObjective] || { action_type: "link_click", label: "Link Clicks" }; +}; + +const calculateBudget = (campaign) => { + if (campaign.daily_budget) { + return { value: campaign.daily_budget / 100, type: "day" }; + } + if (campaign.lifetime_budget) { + return { value: campaign.lifetime_budget / 100, type: "lifetime" }; + } + + const adsets = campaign.adsets?.data || []; + const dailyTotal = adsets.reduce((sum, adset) => sum + (adset.daily_budget || 0), 0); + const lifetimeTotal = adsets.reduce((sum, adset) => sum + (adset.lifetime_budget || 0), 0); + + if (dailyTotal > 0) return { value: dailyTotal / 100, type: "day" }; + if (lifetimeTotal > 0) return { value: lifetimeTotal / 100, type: "lifetime" }; + + return { value: 0, type: "day" }; +}; + +const processMetrics = (campaign) => { + const insights = campaign.insights?.data?.[0] || {}; + const spend = parseFloat(insights.spend || 0); + const impressions = parseInt(insights.impressions || 0); + const clicks = parseInt(insights.clicks || 0); + const reach = parseInt(insights.reach || 0); + const cpc = parseFloat(insights.cpc || 0); + const ctr = parseFloat(insights.ctr || 0); + const cpm = parseFloat(insights.cpm || 0); + const frequency = parseFloat(insights.frequency || 0); + + // Purchase value and total purchases + const purchaseValue = (insights.action_values || []) + .filter(({ action_type }) => action_type === "purchase") + .reduce((sum, { value }) => sum + parseFloat(value || 0), 0); + + const totalPurchases = (insights.actions || []) + .filter(({ action_type }) => action_type === "purchase") + .reduce((sum, { value }) => sum + parseInt(value || 0), 0); + + // Aggregate unique actions + const actionMap = new Map(); + (insights.actions || []).forEach(({ action_type, value }) => { + const currentValue = actionMap.get(action_type) || 0; + actionMap.set(action_type, currentValue + parseInt(value || 0)); + }); + + const actions = Array.from(actionMap.entries()).map(([action_type, value]) => ({ + action_type, + value, + })); + + // Map of cost per action + const costPerActionMap = new Map(); + (insights.cost_per_action_type || []).forEach(({ action_type, value }) => { + costPerActionMap.set(action_type, parseFloat(value || 0)); + }); + + // Total post engagements + const totalPostEngagements = actionMap.get("post_engagement") || 0; + + return { + spend, + impressions, + clicks, + reach, + frequency, + ctr, + cpm, + cpc, + actions, + costPerActionMap, + purchaseValue, + totalPurchases, + totalPostEngagements, + }; +}; + +const processCampaignData = (campaign) => { + const metrics = processMetrics(campaign); + const budget = calculateBudget(campaign); + const { action_type, label } = getObjectiveAction(campaign.objective); + + // Get cost per result from costPerActionMap + const costPerResult = metrics.costPerActionMap.get(action_type) || 0; + + return { + id: campaign.id, + name: campaign.name, + status: campaign.status, + objective: label, + objectiveActionType: action_type, + budget: budget.value, + budgetType: budget.type, + metrics: { + ...metrics, + costPerResult, + }, + }; +}; + const MetaCampaigns = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [campaigns, setCampaigns] = useState([]); - const [timeframe, setTimeframe] = useState("30"); + const [timeframe, setTimeframe] = useState("7"); const [summaryMetrics, setSummaryMetrics] = useState(null); + const [sortConfig, setSortConfig] = useState({ + key: "spend", + direction: "desc", + }); + + const handleSort = (key) => { + setSortConfig((prev) => ({ + key, + direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", + })); + }; const computeDateRange = (timeframe) => { // Create date in Eastern Time @@ -168,35 +314,8 @@ const MetaCampaigns = () => { 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 || [] - } - }; - }); - + // Process campaigns with the new processing logic + const processedCampaigns = campaignsJson.map(processCampaignData); const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0); setCampaigns(activeCampaigns); @@ -241,6 +360,35 @@ const MetaCampaigns = () => { fetchMetaAdsData(); }, [timeframe]); + // Sort campaigns + const sortedCampaigns = [...campaigns].sort((a, b) => { + const direction = sortConfig.direction === "desc" ? -1 : 1; + + switch (sortConfig.key) { + case "date": + // Add date sorting using campaign ID (Meta IDs are chronological) + return direction * (parseInt(b.id) - parseInt(a.id)); + case "spend": + return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0)); + case "reach": + return direction * ((a.metrics.reach || 0) - (b.metrics.reach || 0)); + case "impressions": + return direction * ((a.metrics.impressions || 0) - (b.metrics.impressions || 0)); + case "cpm": + return direction * ((a.metrics.cpm || 0) - (b.metrics.cpm || 0)); + case "ctr": + return direction * ((a.metrics.ctr || 0) - (b.metrics.ctr || 0)); + case "results": + return direction * ((getActionValue(a, a.objectiveActionType) || 0) - (getActionValue(b, b.objectiveActionType) || 0)); + case "value": + return direction * ((a.metrics.purchaseValue || 0) - (b.metrics.purchaseValue || 0)); + case "engagements": + return direction * ((a.metrics.totalPostEngagements || 0) - (b.metrics.totalPostEngagements || 0)); + default: + return 0; + } + }); + if (loading) { return ( @@ -264,7 +412,7 @@ const MetaCampaigns = () => { return ( -
+
Meta Ads Performance @@ -281,58 +429,70 @@ const MetaCampaigns = () => {
-
+
{[ { label: "Active Campaigns", value: summaryMetrics?.totalCampaigns, + options: { icon: Target, iconColor: "text-purple-500" }, }, { label: "Total Spend", value: summaryMetrics?.totalSpend, - options: { isMonetary: true, decimalPlaces: 0 }, + options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-green-500" }, + }, + { + label: "Total Reach", + value: summaryMetrics?.totalReach, + options: { icon: Users, iconColor: "text-blue-500" }, }, - { label: "Total Reach", value: summaryMetrics?.totalReach }, { label: "Total Impressions", value: summaryMetrics?.totalImpressions, + options: { icon: Eye, iconColor: "text-indigo-500" }, }, { label: "Avg Frequency", value: summaryMetrics?.avgFrequency, - options: { decimalPlaces: 2 }, + options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" }, }, { label: "Total Engagements", value: summaryMetrics?.totalPostEngagements, + options: { icon: MessageCircle, iconColor: "text-pink-500" }, }, { label: "Avg CPM", value: summaryMetrics?.avgCpm, - options: { isMonetary: true, decimalPlaces: 2 }, + options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" }, }, { label: "Avg CTR", value: summaryMetrics?.avgCtr, - options: { isPercentage: true, decimalPlaces: 2 }, + options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" }, }, { label: "Avg CPC", value: summaryMetrics?.avgCpc, - options: { isMonetary: true, decimalPlaces: 2 }, + options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" }, }, { label: "Total Link Clicks", value: summaryMetrics?.totalLinkClicks, + options: { icon: MousePointer, iconColor: "text-amber-500" }, + }, + { + label: "Total Purchases", + value: summaryMetrics?.totalPurchases, + options: { icon: ShoppingCart, iconColor: "text-teal-500" }, }, - { label: "Total Purchases", value: summaryMetrics?.totalPurchases }, { label: "Purchase Value", value: summaryMetrics?.totalPurchaseValue, - options: { isMonetary: true, decimalPlaces: 0 }, + options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" }, }, ].map((card) => ( -
+
{summaryCard(card.label, card.value, card.options)}
))} @@ -345,37 +505,91 @@ const MetaCampaigns = () => { - - - - - - - - - - {campaigns.map((campaign) => ( + {sortedCampaigns.map((campaign) => ( { } /> - + { value={campaign.metrics.purchaseValue} isMonetary decimalPlaces={2} - sublabel={`${formatCurrency(campaign.metrics.costPerResult)}/result`} + sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null} /> - + ))} diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js b/examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js new file mode 100644 index 0000000..fde8418 --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js @@ -0,0 +1,176 @@ +import axios from "axios"; + +class MetaAdsService { + constructor() { + this.client = axios.create({ + baseURL: "/api/meta-ads", + headers: { + "Content-Type": "application/json", + }, + }); + } + + // metaAdsService.js + + processMetrics(campaign) { + const insights = campaign.insights?.data?.[0] || {}; + const spend = parseFloat(insights.spend || 0); + const impressions = parseInt(insights.impressions || 0); + const clicks = parseInt(insights.clicks || 0); + const reach = parseInt(insights.reach || 0); + const cpc = parseFloat(insights.cpc || 0); + const ctr = parseFloat(insights.ctr || 0); + const cpm = parseFloat(insights.cpm || 0); + const frequency = parseFloat(insights.frequency || 0); + + // Purchase value and total purchases + const purchaseValue = (insights.action_values || []) + .filter(({ action_type }) => action_type === "purchase") + .reduce((sum, { value }) => sum + parseFloat(value || 0), 0); + + const totalPurchases = (insights.actions || []) + .filter(({ action_type }) => action_type === "purchase") + .reduce((sum, { value }) => sum + parseInt(value || 0), 0); + + // Aggregate unique actions + const actionMap = new Map(); + (insights.actions || []).forEach(({ action_type, value }) => { + const currentValue = actionMap.get(action_type) || 0; + actionMap.set(action_type, currentValue + parseInt(value || 0)); + }); + + const actions = Array.from(actionMap.entries()).map( + ([action_type, value]) => ({ + action_type, + value, + }) + ); + + // Map of cost per action + const costPerActionMap = new Map(); + (insights.cost_per_action_type || []).forEach(({ action_type, value }) => { + costPerActionMap.set(action_type, parseFloat(value || 0)); + }); + + // Total post engagements + const totalPostEngagements = actionMap.get("post_engagement") || 0; + + return { + spend, + impressions, + clicks, + reach, + frequency, + ctr, + cpm, + cpc, + actions, + costPerActionMap, // Include cost per action map + purchaseValue, + totalPurchases, + totalPostEngagements, // Include total post engagements + }; + } + + getObjectiveAction(campaignObjective) { + const objectiveMap = { + OUTCOME_AWARENESS: { action_type: "impressions", label: "Impressions" }, + OUTCOME_ENGAGEMENT: { + action_type: "post_engagement", + label: "Post Engagements", + }, + OUTCOME_TRAFFIC: { action_type: "link_click", label: "Link Clicks" }, + OUTCOME_LEADS: { action_type: "lead", label: "Leads" }, + OUTCOME_SALES: { action_type: "purchase", label: "Purchases" }, + MESSAGES: { action_type: "messages", label: "Messages" }, + // Add other objectives as needed + }; + + return ( + objectiveMap[campaignObjective] || { + action_type: "link_click", + label: "Link Clicks", + } + ); + } + calculateBudget(campaign) { + if (campaign.daily_budget) { + return { value: campaign.daily_budget / 100, type: "day" }; + } + if (campaign.lifetime_budget) { + return { value: campaign.lifetime_budget / 100, type: "lifetime" }; + } + + const adsets = campaign.adsets?.data || []; + const dailyTotal = adsets.reduce( + (sum, adset) => sum + (adset.daily_budget || 0), + 0 + ); + const lifetimeTotal = adsets.reduce( + (sum, adset) => sum + (adset.lifetime_budget || 0), + 0 + ); + + if (dailyTotal > 0) return { value: dailyTotal / 100, type: "day" }; + if (lifetimeTotal > 0) + return { value: lifetimeTotal / 100, type: "lifetime" }; + + return { value: 0, type: "day" }; + } + + processCampaignData(campaign) { + const metrics = this.processMetrics(campaign); + const budget = this.calculateBudget(campaign); + const { action_type, label } = this.getObjectiveAction(campaign.objective); + + // Get cost per result from costPerActionMap + const costPerResult = metrics.costPerActionMap.get(action_type) || 0; + + return { + id: campaign.id, + name: campaign.name, + status: campaign.status, + objective: label, // User-friendly objective label + objectiveActionType: action_type, // Action type for metrics + budget: budget.value, + budgetType: budget.type, + metrics: { + ...metrics, + costPerResult, + }, + }; + } + async getCampaigns({ since, until }) { + try { + const response = await this.client.get("/campaigns", { + params: { since, until }, + }); + + return response.data.map((campaign) => + this.processCampaignData(campaign) + ); + } catch (error) { + console.error( + "Error fetching campaigns:", + error.response?.data || error.message + ); + throw error; + } + } + async getAccountInsights({ since, until }) { + try { + const response = await this.client.get("/account_insights", { + params: { since, until }, + }); + return response.data; // This will be an object with reach and other fields + } catch (error) { + console.error( + "Error fetching account insights:", + error.response?.data || error.message + ); + throw error; + } + } +} + +export const metaAdsService = new MetaAdsService();
- Campaign + + - Spend + + - Reach + + - Impressions + + - CPM + + - CTR + + - Results + + - Value + + - Engagements + +