Add sorting and fix remaining data processing, style cards
This commit is contained in:
@@ -13,7 +13,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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
|
// Helper functions for formatting
|
||||||
const formatCurrency = (value, decimalPlaces = 2) =>
|
const formatCurrency = (value, decimalPlaces = 2) =>
|
||||||
@@ -39,6 +53,8 @@ const summaryCard = (label, value, options = {}) => {
|
|||||||
isMonetary = false,
|
isMonetary = false,
|
||||||
isPercentage = false,
|
isPercentage = false,
|
||||||
decimalPlaces = 0,
|
decimalPlaces = 0,
|
||||||
|
icon: Icon,
|
||||||
|
iconColor,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let displayValue;
|
let displayValue;
|
||||||
@@ -51,12 +67,19 @@ const summaryCard = (label, value, options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
<Card>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
|
<CardContent className="pt-6">
|
||||||
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="flex justify-between items-start">
|
||||||
{displayValue}
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-2xl font-bold">{displayValue}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={`h-5 w-5 ${iconColor || "text-blue-500"}`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,7 +92,7 @@ const MetricCell = ({
|
|||||||
decimalPlaces = 0,
|
decimalPlaces = 0,
|
||||||
}) => (
|
}) => (
|
||||||
<td className="p-2 text-center align-top">
|
<td className="p-2 text-center align-top">
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-left font-semibold">
|
<div className="text-blue-600 dark:text-blue-400 text-center font-semibold">
|
||||||
{isMonetary
|
{isMonetary
|
||||||
? formatCurrency(value, decimalPlaces)
|
? formatCurrency(value, decimalPlaces)
|
||||||
: isPercentage
|
: isPercentage
|
||||||
@@ -77,12 +100,12 @@ const MetricCell = ({
|
|||||||
: formatNumber(value, decimalPlaces)}
|
: formatNumber(value, decimalPlaces)}
|
||||||
</div>
|
</div>
|
||||||
{label && (
|
{label && (
|
||||||
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sublabel && (
|
{sublabel && (
|
||||||
<div className="text-xs text-left text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||||
{sublabel}
|
{sublabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -115,12 +138,135 @@ const CampaignName = ({ name }) => {
|
|||||||
return <span>{name}</span>;
|
return <span>{name}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 MetaCampaigns = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [campaigns, setCampaigns] = useState([]);
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
const [timeframe, setTimeframe] = useState("30");
|
const [timeframe, setTimeframe] = useState("7");
|
||||||
const [summaryMetrics, setSummaryMetrics] = useState(null);
|
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) => {
|
const computeDateRange = (timeframe) => {
|
||||||
// Create date in Eastern Time
|
// Create date in Eastern Time
|
||||||
@@ -168,35 +314,8 @@ const MetaCampaigns = () => {
|
|||||||
accountInsights.json()
|
accountInsights.json()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Process campaigns to match the expected format
|
// Process campaigns with the new processing logic
|
||||||
const processedCampaigns = campaignsJson.map(campaign => {
|
const processedCampaigns = campaignsJson.map(processCampaignData);
|
||||||
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);
|
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
|
||||||
setCampaigns(activeCampaigns);
|
setCampaigns(activeCampaigns);
|
||||||
|
|
||||||
@@ -241,6 +360,35 @@ const MetaCampaigns = () => {
|
|||||||
fetchMetaAdsData();
|
fetchMetaAdsData();
|
||||||
}, [timeframe]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white dark:bg-gray-900">
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
@@ -264,7 +412,7 @@ const MetaCampaigns = () => {
|
|||||||
return (
|
return (
|
||||||
<Card className="h-full bg-white dark:bg-gray-900">
|
<Card className="h-full bg-white dark:bg-gray-900">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<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 Ads Performance
|
Meta Ads Performance
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -281,58 +429,70 @@ const MetaCampaigns = () => {
|
|||||||
</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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
label: "Active Campaigns",
|
label: "Active Campaigns",
|
||||||
value: summaryMetrics?.totalCampaigns,
|
value: summaryMetrics?.totalCampaigns,
|
||||||
|
options: { icon: Target, iconColor: "text-purple-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Total Spend",
|
label: "Total Spend",
|
||||||
value: summaryMetrics?.totalSpend,
|
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",
|
label: "Total Impressions",
|
||||||
value: summaryMetrics?.totalImpressions,
|
value: summaryMetrics?.totalImpressions,
|
||||||
|
options: { icon: Eye, iconColor: "text-indigo-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Avg Frequency",
|
label: "Avg Frequency",
|
||||||
value: summaryMetrics?.avgFrequency,
|
value: summaryMetrics?.avgFrequency,
|
||||||
options: { decimalPlaces: 2 },
|
options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Total Engagements",
|
label: "Total Engagements",
|
||||||
value: summaryMetrics?.totalPostEngagements,
|
value: summaryMetrics?.totalPostEngagements,
|
||||||
|
options: { icon: MessageCircle, iconColor: "text-pink-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Avg CPM",
|
label: "Avg CPM",
|
||||||
value: summaryMetrics?.avgCpm,
|
value: summaryMetrics?.avgCpm,
|
||||||
options: { isMonetary: true, decimalPlaces: 2 },
|
options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Avg CTR",
|
label: "Avg CTR",
|
||||||
value: summaryMetrics?.avgCtr,
|
value: summaryMetrics?.avgCtr,
|
||||||
options: { isPercentage: true, decimalPlaces: 2 },
|
options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Avg CPC",
|
label: "Avg CPC",
|
||||||
value: summaryMetrics?.avgCpc,
|
value: summaryMetrics?.avgCpc,
|
||||||
options: { isMonetary: true, decimalPlaces: 2 },
|
options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Total Link Clicks",
|
label: "Total Link Clicks",
|
||||||
value: summaryMetrics?.totalLinkClicks,
|
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",
|
label: "Purchase Value",
|
||||||
value: summaryMetrics?.totalPurchaseValue,
|
value: summaryMetrics?.totalPurchaseValue,
|
||||||
options: { isMonetary: true, decimalPlaces: 0 },
|
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" },
|
||||||
},
|
},
|
||||||
].map((card) => (
|
].map((card) => (
|
||||||
<div key={card.label}>
|
<div key={card.label} className="min-w-[140px]">
|
||||||
{summaryCard(card.label, card.value, card.options)}
|
{summaryCard(card.label, card.value, card.options)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -345,37 +505,91 @@ const MetaCampaigns = () => {
|
|||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
<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 sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="pl-0 justify-start w-full"
|
||||||
|
onClick={() => handleSort("date")}
|
||||||
|
>
|
||||||
Campaign
|
Campaign
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center 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"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("spend")}
|
||||||
|
>
|
||||||
Spend
|
Spend
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "reach" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("reach")}
|
||||||
|
>
|
||||||
Reach
|
Reach
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("impressions")}
|
||||||
|
>
|
||||||
Impressions
|
Impressions
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("cpm")}
|
||||||
|
>
|
||||||
CPM
|
CPM
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center 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"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("ctr")}
|
||||||
|
>
|
||||||
CTR
|
CTR
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "results" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("results")}
|
||||||
|
>
|
||||||
Results
|
Results
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "value" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("value")}
|
||||||
|
>
|
||||||
Value
|
Value
|
||||||
|
</Button>
|
||||||
</th>
|
</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">
|
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900 z-10 text-gray-900 dark:text-gray-100">
|
||||||
|
<Button
|
||||||
|
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleSort("engagements")}
|
||||||
|
>
|
||||||
Engagements
|
Engagements
|
||||||
|
</Button>
|
||||||
</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">
|
||||||
{campaigns.map((campaign) => (
|
{sortedCampaigns.map((campaign) => (
|
||||||
<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"
|
||||||
@@ -400,11 +614,13 @@ const MetaCampaigns = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell value={campaign.metrics.reach} />
|
<MetricCell
|
||||||
|
value={campaign.metrics.reach}
|
||||||
|
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
|
||||||
|
/>
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
value={campaign.metrics.impressions}
|
value={campaign.metrics.impressions}
|
||||||
label={`${campaign.metrics.frequency.toFixed(2)}x freq`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell
|
<MetricCell
|
||||||
@@ -429,10 +645,12 @@ const MetaCampaigns = () => {
|
|||||||
value={campaign.metrics.purchaseValue}
|
value={campaign.metrics.purchaseValue}
|
||||||
isMonetary
|
isMonetary
|
||||||
decimalPlaces={2}
|
decimalPlaces={2}
|
||||||
sublabel={`${formatCurrency(campaign.metrics.costPerResult)}/result`}
|
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell value={campaign.metrics.totalPostEngagements} />
|
<MetricCell
|
||||||
|
value={campaign.metrics.totalPostEngagements}
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
176
examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js
Normal file
176
examples DO NOT USE OR EDIT/EXAMPLE ONLY metaAdsService.js
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user