Files
dashboard/examples DO NOT USE OR EDIT/EXAMPLE ONLY KlaviyoCampaigns.jsx
2024-12-21 09:49:53 -05:00

317 lines
11 KiB
JavaScript

import React, { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateTime } from "luxon";
// Helper functions for formatting
const formatRate = (value) => {
if (typeof value !== "number") return "0.0%";
return `${(value * 100).toFixed(1)}%`;
};
const formatCurrency = (value) => {
if (typeof value !== "number") return "$0";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).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 mb-4 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,
showConversionRate = false,
totalRecipients = 0,
}) => (
<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} {count === 1 ? "recipient" : "recipients"}
{showConversionRate &&
totalRecipients > 0 &&
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
</div>
</td>
);
const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
const [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [sortConfig, setSortConfig] = useState({
key: "send_time",
direction: "desc",
});
const fetchInProgress = useRef(false);
const fetchCampaigns = async () => {
console.log("Component fetching campaigns...", {
timeRange,
timestamp: new Date().toISOString()
});
try {
setIsLoading(true);
const response = await fetch(`/api/klaviyo/campaigns/${timeRange}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Campaign fetch error response:", errorText);
throw new Error(`Failed to fetch campaigns: ${response.status}`);
}
const data = await response.json();
console.log("Received campaign data:", {
type: data?.type,
count: data?.data?.length,
sample: data?.data?.[0],
structure: {
topLevel: Object.keys(data || {}),
firstCampaign: data?.data?.[0] ? Object.keys(data.data[0]) : null,
stats: data?.data?.[0]?.stats ? Object.keys(data.data[0].stats) : null
}
});
// Handle the new data structure
const campaignsData = data?.data || [];
if (!Array.isArray(campaignsData)) {
throw new Error('Invalid campaign data format received');
}
// Process campaigns to ensure consistent structure
const processedCampaigns = campaignsData.map(campaign => ({
id: campaign.id,
name: campaign.name || "Unnamed Campaign",
subject: campaign.subject || "",
send_time: campaign.send_time,
stats: {
delivery_rate: campaign.stats?.delivery_rate || 0,
delivered: campaign.stats?.delivered || 0,
recipients: campaign.stats?.recipients || 0,
open_rate: campaign.stats?.open_rate || 0,
opens_unique: campaign.stats?.opens_unique || 0,
opens: campaign.stats?.opens || 0,
clicks_unique: campaign.stats?.clicks_unique || 0,
click_rate: campaign.stats?.click_rate || 0,
click_to_open_rate: campaign.stats?.click_to_open_rate || 0,
conversion_value: campaign.stats?.conversion_value || 0,
conversion_uniques: campaign.stats?.conversion_uniques || 0
}
}));
console.log("Processed campaigns:", {
count: processedCampaigns.length,
sample: processedCampaigns[0]
});
setCampaigns(processedCampaigns);
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);
}, [timeRange]);
// Add this to debug render
console.log("Rendering campaigns:", {
count: campaigns?.length,
isLoading,
error
});
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
const direction = sortConfig.direction === "desc" ? -1 : 1;
if (sortConfig.key === "send_time") {
return (
direction *
(DateTime.fromISO(a.send_time) - DateTime.fromISO(b.send_time))
);
}
// Handle nested stats properties
if (sortConfig.key.startsWith("stats.")) {
const statKey = sortConfig.key.split(".")[1];
return direction * (a.stats[statKey] - b.stats[statKey]);
}
return direction * (a[sortConfig.key] - b[sortConfig.key]);
});
// Filter campaigns by search term
const filteredCampaigns = sortedCampaigns.filter(
(campaign) =>
campaign &&
campaign.name && // verify campaign and name exist
campaign.name.toLowerCase().includes((searchTerm || "").toLowerCase())
);
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">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Email Campaigns
</CardTitle>
</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">
Delivery
</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">
Opens
</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">
Clicks
</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">
CTR
</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">
Orders
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredCampaigns.map(
(campaign) =>
campaign && (
<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="font-medium text-gray-900 dark:text-gray-100">
{campaign.name || "Unnamed Campaign"}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-[300px]">
{campaign.subject || "No subject"}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
{campaign.send_time
? DateTime.fromISO(
campaign.send_time
).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</td>
</TooltipTrigger>
<TooltipContent
side="top"
className="break-words bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border dark:border-gray-700"
>
<p className="font-medium">
{campaign.name || "Unnamed Campaign"}
</p>
<p>{campaign.subject || "No subject"}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{campaign.send_time
? DateTime.fromISO(
campaign.send_time
).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MetricCell
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
/>
<MetricCell
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
/>
<MetricCell
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
/>
<MetricCell
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
/>
<MetricCell
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
/>
</tr>
)
)}
</tbody>
</table>
</CardContent>
</Card>
);
};
export default KlaviyoCampaigns;