Add meta component and services
This commit is contained in:
@@ -22,6 +22,7 @@ import StatCards from "./components/dashboard/StatCards";
|
||||
import ProductGrid from "./components/dashboard/ProductGrid";
|
||||
import SalesChart from "./components/dashboard/SalesChart";
|
||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
@@ -89,7 +90,7 @@ const DashboardLayout = () => {
|
||||
</div>
|
||||
<Navigation />
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
<MetaCampaigns />
|
||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||
<div className="xl:col-span-4 col-span-6">
|
||||
<div className="space-y-4 h-full w-full">
|
||||
|
||||
325
dashboard/src/components/dashboard/MetaCampaigns.jsx
Normal file
325
dashboard/src/components/dashboard/MetaCampaigns.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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";
|
||||
|
||||
// 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", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const TableSkeleton = () => (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 bg-gray-100 dark:bg-gray-800 animate-pulse rounded"
|
||||
/>
|
||||
))}
|
||||
</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 = ({
|
||||
value,
|
||||
count,
|
||||
isMonetary = false,
|
||||
label = "",
|
||||
tooltipText = "",
|
||||
}) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<td className="p-2 text-center">
|
||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||
{isMonetary ? formatCurrency(value) : formatRate(value)}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
{count?.toLocaleString() || 0} {label}
|
||||
</div>
|
||||
</td>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const MetaCampaigns = ({ className }) => {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: "spend",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSortConfig((prev) => ({
|
||||
key,
|
||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||
}));
|
||||
};
|
||||
|
||||
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}`
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="h-6 w-48 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px]">
|
||||
<TableSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900">
|
||||
{error && <ErrorAlert description={error} />}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Meta Ad Campaigns
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px] mb-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 font-medium text-center 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
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("impressions")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Impressions
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant={sortConfig.key === "clicks" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("clicks")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Clicks
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("ctr")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
CTR
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant={sortConfig.key === "cpc" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("cpc")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
CPC
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{sortedCampaigns.map((campaign) => {
|
||||
const insights = campaign.insights?.data?.[0] || {};
|
||||
return (
|
||||
<tr
|
||||
key={campaign.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<td className="p-2 align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
{campaign.status === 'ACTIVE' ? (
|
||||
<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 className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{campaign.objective}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Budget: {formatCurrency(campaign.daily_budget / 100)}
|
||||
</div>
|
||||
</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
|
||||
value={insights.spend}
|
||||
isMonetary={true}
|
||||
tooltipText="Total amount spent"
|
||||
/>
|
||||
<MetricCell
|
||||
value={insights.impressions}
|
||||
count={insights.impressions}
|
||||
label="views"
|
||||
tooltipText="Number of times your ads were viewed"
|
||||
/>
|
||||
<MetricCell
|
||||
value={insights.clicks}
|
||||
count={insights.clicks}
|
||||
label="clicks"
|
||||
tooltipText="Number of clicks on your ads"
|
||||
/>
|
||||
<MetricCell
|
||||
value={insights.ctr}
|
||||
tooltipText="Click-through rate (Clicks / Impressions)"
|
||||
/>
|
||||
<MetricCell
|
||||
value={insights.cpc}
|
||||
isMonetary={true}
|
||||
tooltipText="Average cost per click"
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetaCampaigns;
|
||||
Reference in New Issue
Block a user