Add meta component and services

This commit is contained in:
2024-12-27 13:59:51 -05:00
parent 6cc74de9a1
commit e7f7aec93b
16 changed files with 1781 additions and 1 deletions

View File

@@ -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">

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