Move more of dashboard to shared components

This commit is contained in:
2026-01-18 16:52:00 -05:00
parent 54ddaa0492
commit 630945e901
14 changed files with 1362 additions and 1692 deletions

View File

@@ -8,14 +8,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
BarChart,
Bar,
@@ -34,10 +26,9 @@ import {
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
ChartSkeleton,
TableSkeleton,
CARD_STYLES,
SCROLL_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
import { Phone, Clock, Zap, Timer } from "lucide-react";
@@ -73,47 +64,6 @@ const formatDuration = (seconds) => {
return `${minutes}m ${remainingSeconds}s`;
};
const AgentPerformanceTable = ({ agents, onSort }) => {
const [sortConfig, setSortConfig] = useState({
key: "total",
direction: "desc",
});
const handleSort = (key) => {
const direction =
sortConfig.key === key && sortConfig.direction === "desc"
? "asc"
: "desc";
setSortConfig({ key, direction });
onSort(key, direction);
};
return (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Agent</TableHead>
<TableHead onClick={() => handleSort("total")} className="cursor-pointer">Total Calls</TableHead>
<TableHead onClick={() => handleSort("answered")} className="cursor-pointer">Answered</TableHead>
<TableHead onClick={() => handleSort("missed")} className="cursor-pointer">Missed</TableHead>
<TableHead onClick={() => handleSort("average_duration")} className="cursor-pointer">Average Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((agent) => (
<TableRow key={agent.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-foreground">{agent.name}</TableCell>
<TableCell>{agent.total}</TableCell>
<TableCell className="text-trend-positive">{agent.answered}</TableCell>
<TableCell className="text-trend-negative">{agent.missed}</TableCell>
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
const AircallDashboard = () => {
const [timeRange, setTimeRange] = useState("last7days");
const [metrics, setMetrics] = useState(null);
@@ -163,6 +113,58 @@ const AircallDashboard = () => {
})),
};
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "total",
header: "Total Calls",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "answered",
header: "Answered",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-positive">{value}</span>,
},
{
key: "missed",
header: "Missed",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-negative">{value}</span>,
},
{
key: "average_duration",
header: "Avg Duration",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
},
];
// Column definitions for Missed Reasons table
const missedReasonsColumns = [
{
key: "reason",
header: "Reason",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "count",
header: "Count",
align: "right",
render: (value) => <span className="text-trend-negative">{value}</span>,
},
];
const fetchData = async () => {
try {
setIsLoading(true);
@@ -363,16 +365,17 @@ const AircallDashboard = () => {
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact />
<CardContent>
{isLoading ? (
<TableSkeleton rows={5} columns={5} />
) : (
<div className={SCROLL_STYLES.md}>
<AgentPerformanceTable
agents={sortedAgents}
<DashboardTable
columns={agentColumns}
data={sortedAgents}
loading={isLoading}
skeletonRows={5}
getRowKey={(agent) => agent.id}
sortConfig={agentSort}
onSort={(key, direction) => setAgentSort({ key, direction })}
maxHeight="md"
compact
/>
</div>
)}
</CardContent>
</Card>
@@ -380,32 +383,15 @@ const AircallDashboard = () => {
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Missed Call Reasons" compact />
<CardContent>
{isLoading ? (
<TableSkeleton rows={5} columns={2} />
) : (
<div className={SCROLL_STYLES.md}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="font-medium">Reason</TableHead>
<TableHead className="text-right font-medium">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{chartData.missedReasons.map((reason, index) => (
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-foreground">
{reason.reason}
</TableCell>
<TableCell className="text-right text-trend-negative">
{reason.count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DashboardTable
columns={missedReasonsColumns}
data={chartData.missedReasons}
loading={isLoading}
skeletonRows={5}
getRowKey={(reason, index) => `${reason.reason}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -22,12 +22,14 @@ import { TrendingUp } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardChartTooltip,
ChartSkeleton,
DashboardEmptyState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
// Note: Using ChartSkeleton from @/components/dashboard/shared
@@ -35,15 +37,7 @@ import {
const SkeletonStats = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className={CARD_STYLES.base}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-8 w-32 bg-muted rounded-sm mb-2" />
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardContent>
</Card>
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
</div>
);
@@ -165,21 +159,23 @@ export const AnalyticsDashboard = () => {
const summaryStats = calculateSummaryStats();
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className={TYPOGRAPHY.sectionTitle}>
Analytics Overview
</CardTitle>
</div>
<div className="flex items-center gap-2">
{loading ? (
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
) : (
<>
// Time selector for DashboardSectionHeader
const timeSelector = (
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
);
// Header actions: Details dialog
const headerActions = !loading ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
@@ -264,26 +260,29 @@ export const AnalyticsDashboard = () => {
</div>
</DialogContent>
</Dialog>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
</div>
) : <Skeleton className="h-9 w-20 bg-muted rounded-sm" />;
// Label formatter for chart tooltip
const analyticsLabelFormatter = (label) => {
const date = label instanceof Date ? label : new Date(label);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Analytics Overview"
loading={loading}
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Stats cards */}
{loading ? (
<SkeletonStats />
) : summaryStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<DashboardStatCard
title="Active Users"
value={summaryStats.totals.activeUsers.toLocaleString()}
@@ -319,7 +318,8 @@ export const AnalyticsDashboard = () => {
</div>
) : null}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
{/* Metric toggles */}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.activeUsers ? "default" : "outline"}
@@ -379,10 +379,6 @@ export const AnalyticsDashboard = () => {
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? (
<ChartSkeleton height="default" withCard={false} />
) : !data.length ? (
@@ -420,34 +416,12 @@ export const AnalyticsDashboard = () => {
tick={{ fill: "currentColor" }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const date = payload[0]?.payload?.date;
const formattedDate = date instanceof Date
? date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
: String(date);
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
<div className={TOOLTIP_STYLES.content}>
{payload.map((entry, i) => (
<div key={i} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || "#888" }}
content={
<DashboardChartTooltip
labelFormatter={analyticsLabelFormatter}
valueFormatter={(value) => value.toLocaleString()}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>
{entry.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
);
}}
}
/>
<Legend />
{metrics.activeUsers && (

View File

@@ -1,11 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -42,7 +37,6 @@ import {
YAxis,
} from "recharts";
import type { TooltipProps } from "recharts";
import { Skeleton } from "@/components/ui/skeleton";
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
import PeriodSelectionPopover, {
type QuickPreset,
@@ -50,8 +44,10 @@ import PeriodSelectionPopover, {
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
@@ -1102,20 +1098,9 @@ const FinancialOverview = () => {
};
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-foreground">
Profit & Loss Overview
</CardTitle>
</div>
<div className="flex items-center gap-2">
{!error && (
// Header actions: Details dialog and Period selector
const headerActions = !error ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
@@ -1239,24 +1224,30 @@ const FinancialOverview = () => {
onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult}
/>
</>
)}
</div>
</div>
) : null;
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Profit & Loss Overview"
size="large"
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Show stats only if not in error state */}
{!error &&
(loading ? (
{!error && (
loading ? (
<SkeletonStats />
) : (
cards.length > 0 && <FinancialStatGrid cards={cards} />
))}
)
)}
{/* Show metric toggles only if not in error state */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => (
<Button
@@ -1297,12 +1288,9 @@ const FinancialOverview = () => {
</div>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? (
<div className="space-y-6">
<SkeletonChart />
<SkeletonChartSection />
</div>
) : error ? (
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
@@ -1496,54 +1484,9 @@ function SkeletonStats() {
);
}
function SkeletonChart() {
function SkeletonChartSection() {
return (
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{/* Grid lines */}
{[...Array(6)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted/30"
style={{ top: `${(i + 1) * 16}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
))}
</div>
{/* Chart area */}
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
<div
className="absolute inset-0 bg-muted/20"
style={{
clipPath:
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
{/* Simulated line chart */}
<div
className="absolute inset-0 bg-blue-500/30"
style={{
clipPath:
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 40%, 90% 45%, 75% 30%, 60% 50%, 45% 35%, 30% 55%, 15% 45%, 0 60%)",
height: "2px",
top: "50%",
}}
/>
</div>
</div>
</div>
</div>
<ChartSkeleton type="area" height="default" withCard={false} />
);
}

View File

@@ -7,15 +7,6 @@ import {
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import {
Mail,
Send,
@@ -34,6 +25,7 @@ import {
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
} from "@/components/dashboard/shared";
const TIME_RANGES = {
@@ -51,14 +43,12 @@ const formatDuration = (seconds) => {
};
const getDateRange = (days) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
if (days === "today") {
// For today, set the range to be the current day in Eastern Time
const start = new Date(easternTime);
start.setHours(0, 0, 0, 0);
@@ -71,7 +61,6 @@ const getDateRange = (days) => {
};
}
// For other periods, calculate from end of previous day
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
@@ -85,28 +74,26 @@ const getDateRange = (days) => {
};
};
const TableSkeleton = () => (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
// Trend cell component with arrow and color
const TrendCell = ({ delta }) => {
if (delta === 0) return null;
const isPositive = delta > 0;
const colorClass = isPositive
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
return (
<div className={`flex items-center justify-end gap-0.5 ${colorClass}`}>
{isPositive ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(delta)}%</span>
</div>
);
};
const GorgiasOverview = () => {
const [timeRange, setTimeRange] = useState("7");
@@ -153,7 +140,6 @@ const GorgiasOverview = () => {
useEffect(() => {
loadStats();
// Set up auto-refresh every 5 minutes
const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadStats]);
@@ -183,21 +169,79 @@ const GorgiasOverview = () => {
}, {});
// Process channel data
const channels = data.channels?.map(line => ({
const channels = (data.channels?.map(line => ({
name: line[0]?.value || '',
total: line[1]?.value || 0,
percentage: line[2]?.value || 0,
delta: line[3]?.value || 0
})) || [];
})) || []).sort((a, b) => b.total - a.total);
// Process agent data
const agents = data.agents?.map(line => ({
const agents = (data.agents?.map(line => ({
name: line[0]?.value || '',
closed: line[1]?.value || 0,
rating: line[2]?.value,
percentage: line[3]?.value || 0,
delta: line[4]?.value || 0
})) || [];
})) || []).filter(agent => agent.name !== "Unassigned");
// Column definitions for Channel Distribution table
const channelColumns = [
{
key: "name",
header: "Channel",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "total",
header: "Total",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "percentage",
header: "%",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}%</span>,
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "closed",
header: "Closed",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "rating",
header: "Rating",
align: "right",
render: (value) => (
<span className="text-muted-foreground">
{value ? `${value}/5` : "-"}
</span>
),
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
if (error) {
return (
@@ -245,7 +289,6 @@ const GorgiasOverview = () => {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Message & Response Metrics */}
{loading ? (
[...Array(7)].map((_, i) => (
<DashboardStatCardSkeleton key={i} size="compact" />
@@ -341,120 +384,32 @@ const GorgiasOverview = () => {
{/* Channel Distribution */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? (
<TableSkeleton />
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-foreground">Channel</TableHead>
<TableHead className="text-right font-medium text-foreground">Total</TableHead>
<TableHead className="text-right font-medium text-foreground">%</TableHead>
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels
.sort((a, b) => b.total - a.total)
.map((channel, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-foreground">
{channel.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.total}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.percentage}%
</TableCell>
<TableCell
className={`text-right ${
channel.delta > 0
? "text-green-600 dark:text-green-500"
: channel.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{channel.delta !== 0 && (
<>
{channel.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(channel.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<CardContent>
<DashboardTable
columns={channelColumns}
data={channels}
loading={loading}
skeletonRows={5}
getRowKey={(channel, index) => `${channel.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
{/* Agent Performance */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? (
<TableSkeleton />
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-foreground">Agent</TableHead>
<TableHead className="text-right font-medium text-foreground">Closed</TableHead>
<TableHead className="text-right font-medium text-foreground">Rating</TableHead>
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents
.filter((agent) => agent.name !== "Unassigned")
.map((agent, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-foreground">
{agent.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.closed}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.rating ? `${agent.rating}/5` : "-"}
</TableCell>
<TableCell
className={`text-right ${
agent.delta > 0
? "text-green-600 dark:text-green-500"
: agent.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{agent.delta !== 0 && (
<>
{agent.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(agent.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<CardContent>
<DashboardTable
columns={agentColumns}
data={agents}
loading={loading}
skeletonRows={5}
getRowKey={(agent, index) => `${agent.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
@@ -17,11 +17,12 @@ import {
import { Button } from "@/components/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared";
// Helper functions for formatting
@@ -41,83 +42,8 @@ const formatCurrency = (value) => {
}).format(value);
};
// Loading skeleton component
const TableSkeleton = () => (
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-24 bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{[...Array(15)].map((_, i) => (
<tr key={i} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
</tr>
))}
</tbody>
</table>
);
// MetricCell component for displaying campaign metrics
const MetricCell = ({
// MetricCell content component for displaying campaign metrics (returns content, not <td>)
const MetricCellContent = ({
value,
count,
isMonetary = false,
@@ -128,15 +54,15 @@ const MetricCell = ({
}) => {
if (isSMS && hideForSMS) {
return (
<td className="p-2 text-center">
<div className="text-center">
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
<div className="text-muted-foreground text-sm">-</div>
</td>
</div>
);
}
return (
<td className="p-2 text-center">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</div>
@@ -146,7 +72,56 @@ const MetricCell = ({
totalRecipients > 0 &&
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
</div>
</td>
</div>
);
};
// Campaign name cell with tooltip
const CampaignCell = ({ campaign }) => {
const isBlog = campaign.name?.includes("_Blog");
const isSMS = campaign.channel === 'sms';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default">
<div className="flex items-center gap-2">
{isBlog ? (
<BookOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : isSMS ? (
<MessageSquare className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<div className="font-medium text-foreground">
{campaign.name}
</div>
</div>
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
{campaign.subject}
</div>
<div className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="break-words bg-card text-foreground border dark:border-gray-800"
>
<p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p>
<p className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -154,7 +129,6 @@ const KlaviyoCampaigns = ({ className }) => {
const [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
const [sortConfig, setSortConfig] = useState({
@@ -162,11 +136,8 @@ const KlaviyoCampaigns = ({ className }) => {
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
};
const fetchCampaigns = async () => {
@@ -193,9 +164,9 @@ const KlaviyoCampaigns = ({ className }) => {
useEffect(() => {
fetchCampaigns();
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000);
return () => clearInterval(interval);
}, [selectedTimeRange]); // Only refresh when time range changes
}, [selectedTimeRange]);
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
@@ -219,16 +190,100 @@ const KlaviyoCampaigns = ({ className }) => {
}
});
// Filter campaigns by search term and channels
// Filter campaigns by channels
const filteredCampaigns = sortedCampaigns.filter(
(campaign) => {
const isBlog = campaign?.name?.includes("_Blog");
const channelType = isBlog ? "blog" : campaign?.channel;
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
selectedChannels[channelType];
return selectedChannels[channelType];
}
);
// Column definitions for DashboardTable
const columns = [
{
key: "name",
header: "Campaign",
sortable: true,
sortKey: "send_time",
render: (_, campaign) => <CampaignCell campaign={campaign} />,
},
{
key: "delivery_rate",
header: "Delivery",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
{
key: "open_rate",
header: "Opens",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
),
},
{
key: "click_rate",
header: "Clicks",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
{
key: "click_to_open_rate",
header: "CTR",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
),
},
{
key: "conversion_value",
header: "Orders",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
];
if (isLoading) {
return (
<Card className={`h-full ${CARD_STYLES.base}`}>
@@ -240,7 +295,7 @@ const KlaviyoCampaigns = ({ className }) => {
timeSelector={<div className="w-[130px]" />}
/>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<TableSkeleton />
<TableSkeleton rows={15} columns={6} variant="detailed" />
</CardContent>
</Card>
);
@@ -316,150 +371,17 @@ const KlaviyoCampaigns = ({ className }) => {
</Select>
}
/>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
<Button
variant="ghost"
onClick={() => handleSort("send_time")}
className="w-full justify-start h-8"
>
Campaign
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
onClick={() => handleSort("delivery_rate")}
className="w-full justify-center h-8"
>
Delivery
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
onClick={() => handleSort("open_rate")}
className="w-full justify-center h-8"
>
Opens
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_rate")}
className="w-full justify-center h-8"
>
Clicks
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_to_open_rate")}
className="w-full justify-center h-8"
>
CTR
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
onClick={() => handleSort("conversion_value")}
className="w-full justify-center h-8"
>
Orders
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filteredCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<td className="p-2 align-top">
<div className="flex items-center gap-2">
{campaign.name?.includes("_Blog") ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : campaign.channel === 'sms' ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
<div className="font-medium text-foreground">
{campaign.name}
</div>
</div>
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
{campaign.subject}
</div>
<div className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</td>
</TooltipTrigger>
<TooltipContent
side="top"
className="break-words bg-card text-foreground border dark:border-gray-800"
>
<p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p>
<p className="text-xs text-muted-foreground">
{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}
isSMS={campaign.channel === 'sms'}
<CardContent className="pl-4 mb-4">
<DashboardTable
columns={columns}
data={filteredCampaigns}
getRowKey={(campaign) => campaign.id}
sortConfig={sortConfig}
onSort={handleSort}
maxHeight="md"
stickyHeader
bordered
/>
<MetricCell
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);

View File

@@ -19,14 +19,14 @@ import {
ShoppingCart,
MessageCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared";
// Helper functions for formatting
@@ -48,8 +48,8 @@ const formatNumber = (value, decimalPlaces = 0) => {
const formatPercent = (value, decimalPlaces = 2) =>
`${(value || 0).toFixed(decimalPlaces)}%`;
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
// MetricCell content component (returns content, not <td>)
const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
const formattedValue = isMonetary
? formatCurrency(value, decimalPlaces)
: isPercentage
@@ -57,7 +57,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
: formatNumber(value, decimalPlaces);
return (
<td className="p-2 text-center align-top">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{formattedValue}
</div>
@@ -66,7 +66,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
{label || sublabel}
</div>
)}
</td>
</div>
);
};
@@ -84,16 +84,27 @@ const getActionValue = (campaign, actionType) => {
return 0;
};
const CampaignName = ({ name }) => {
if (name.startsWith("Instagram post: ")) {
const CampaignNameCell = ({ campaign }) => {
const name = campaign.name;
const isInstagram = name.startsWith("Instagram post: ");
return (
<div>
<div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
{isInstagram ? (
<div className="flex items-center space-x-2">
<Instagram className="w-4 h-4" />
<Instagram className="w-4 h-4 flex-shrink-0" />
<span>{name.replace("Instagram post: ", "")}</span>
</div>
) : (
<span>{name}</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
);
}
return <span>{name}</span>;
};
const getObjectiveAction = (campaignObjective) => {
@@ -138,7 +149,6 @@ const processMetrics = (campaign) => {
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);
@@ -147,7 +157,6 @@ const processMetrics = (campaign) => {
.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;
@@ -159,13 +168,11 @@ const processMetrics = (campaign) => {
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 {
@@ -190,7 +197,6 @@ const processCampaignData = (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 {
@@ -208,49 +214,6 @@ const processCampaignData = (campaign) => {
};
};
const SkeletonTable = () => (
<div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
<table className="min-w-full">
<thead>
<tr className="border-b border-border/50">
<th className="p-2 sticky top-0 bg-card z-10">
<Skeleton className="h-4 w-32 bg-muted" />
</th>
{[...Array(8)].map((_, i) => (
<th key={i} className="p-2 text-center sticky top-0 bg-card z-10">
<Skeleton className="h-4 w-20 mx-auto bg-muted" />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{[...Array(5)].map((_, rowIndex) => (
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
{[...Array(8)].map((_, colIndex) => (
<td key={colIndex} className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
const MetaCampaigns = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -262,30 +225,24 @@ const MetaCampaigns = () => {
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
};
const computeDateRange = (timeframe) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
easternTime.setHours(0, 0, 0, 0); // Set to start of day
easternTime.setHours(0, 0, 0, 0);
let sinceDate, untilDate;
if (timeframe === "today") {
// For today, both dates should be the current date in Eastern Time
sinceDate = untilDate = new Date(easternTime);
} else {
// For other periods, calculate the date range
untilDate = new Date(easternTime);
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
untilDate.setDate(untilDate.getDate() - 1);
sinceDate = new Date(untilDate);
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
@@ -315,7 +272,6 @@ const MetaCampaigns = () => {
accountInsights.json()
]);
// Process campaigns with the new processing logic
const processedCampaigns = campaignsJson.map(processCampaignData);
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
setCampaigns(activeCampaigns);
@@ -367,7 +323,6 @@ const MetaCampaigns = () => {
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));
@@ -390,6 +345,118 @@ const MetaCampaigns = () => {
}
});
// Column definitions for DashboardTable
const columns = [
{
key: "name",
header: "Campaign",
sortable: true,
sortKey: "date",
render: (_, campaign) => <CampaignNameCell campaign={campaign} />,
},
{
key: "spend",
header: "Spend",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.spend}
isMonetary
decimalPlaces={2}
sublabel={
campaign.budget
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
: "Budget: Ad set"
}
/>
),
},
{
key: "reach",
header: "Reach",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.reach}
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
/>
),
},
{
key: "impressions",
header: "Impressions",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.impressions} />
),
},
{
key: "cpm",
header: "CPM",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
),
},
{
key: "ctr",
header: "CTR",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.ctr}
isPercentage
decimalPlaces={2}
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
/>
),
},
{
key: "results",
header: "Results",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={getActionValue(campaign, campaign.objectiveActionType)}
label={campaign.objective}
/>
),
},
{
key: "value",
header: "Value",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.purchaseValue}
isMonetary
decimalPlaces={2}
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
/>
),
},
{
key: "engagements",
header: "Engagements",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.totalPostEngagements} />
),
},
];
if (loading) {
return (
<Card className={`h-full ${CARD_STYLES.base}`}>
@@ -407,7 +474,7 @@ const MetaCampaigns = () => {
</div>
</CardHeader>
<CardContent className="p-4">
<SkeletonTable />
<TableSkeleton rows={5} columns={9} variant="detailed" />
</CardContent>
</Card>
);
@@ -535,162 +602,17 @@ const MetaCampaigns = () => {
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
<Button
variant="ghost"
className="pl-0 justify-start w-full h-8"
onClick={() => handleSort("date")}
>
Campaign
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "spend" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("spend")}
>
Spend
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "reach" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("reach")}
>
Reach
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("impressions")}
>
Impressions
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("cpm")}
>
CPM
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("ctr")}
>
CTR
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "results" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("results")}
>
Results
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "value" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("value")}
>
Value
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("engagements")}
>
Engagements
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{sortedCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<td className="p-2 align-top">
<div>
<div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
<CampaignName name={campaign.name} />
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
</td>
<MetricCell
value={campaign.metrics.spend}
isMonetary
decimalPlaces={2}
sublabel={
campaign.budget
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
: "Budget: Ad set"
}
<CardContent className="pl-4 mb-4">
<DashboardTable
columns={columns}
data={sortedCampaigns}
getRowKey={(campaign) => campaign.id}
sortConfig={sortConfig}
onSort={handleSort}
maxHeight="md"
stickyHeader
bordered
/>
<MetricCell
value={campaign.metrics.reach}
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
/>
<MetricCell
value={campaign.metrics.impressions}
/>
<MetricCell
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
<MetricCell
value={campaign.metrics.ctr}
isPercentage
decimalPlaces={2}
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
/>
<MetricCell
value={getActionValue(campaign, campaign.objectiveActionType)}
label={campaign.objective}
/>
<MetricCell
value={campaign.metrics.purchaseValue}
isMonetary
decimalPlaces={2}
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
/>
<MetricCell
value={campaign.metrics.totalPostEngagements}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);

View File

@@ -15,14 +15,6 @@ import {
TooltipProvider,
} from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/components/ui/table";
import { format } from "date-fns";
// Import shared components and tokens
@@ -30,13 +22,13 @@ import {
DashboardChartTooltip,
DashboardSectionHeader,
DashboardStatCard,
DashboardTable,
StatCardSkeleton,
ChartSkeleton,
TableSkeleton,
DashboardErrorState,
CARD_STYLES,
TYPOGRAPHY,
SCROLL_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
@@ -344,6 +336,36 @@ export const RealtimeAnalytics = () => {
};
}, [isPaused]);
// Column definitions for pages table
const pagesColumns = [
{
key: "path",
header: "Page",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
},
];
// Column definitions for sources table
const sourcesColumns = [
{
key: "source",
header: "Source",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
},
];
if (loading && !basicData && !detailedData) {
return (
<Card className={`${CARD_STYLES.base} h-full`}>
@@ -448,64 +470,28 @@ export const RealtimeAnalytics = () => {
</TabsContent>
<TabsContent value="pages">
<div className={`h-[230px] ${SCROLL_STYLES.container}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-foreground">
Page
</TableHead>
<TableHead className="text-right text-foreground">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.currentPages.map((page, index) => (
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-foreground">
{page.path}
</TableCell>
<TableCell
className={`text-right ${REALTIME_COLORS.pages.className}`}
>
{page.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-[230px]">
<DashboardTable
columns={pagesColumns}
data={detailedData.currentPages}
loading={loading}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="sm"
compact
/>
</div>
</TabsContent>
<TabsContent value="sources">
<div className={`h-[230px] ${SCROLL_STYLES.container}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-foreground">
Source
</TableHead>
<TableHead className="text-right text-foreground">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.sources.map((source, index) => (
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-foreground">
{source.source}
</TableCell>
<TableCell
className={`text-right ${REALTIME_COLORS.sources.className}`}
>
{source.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-[230px]">
<DashboardTable
columns={sourcesColumns}
data={detailedData.sources}
loading={loading}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="sm"
compact
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -1,13 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, memo } from "react";
import axios from "axios";
import React, { useState, useEffect, useCallback, memo } from "react";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -15,16 +8,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Loader2,
TrendingUp,
TrendingDown,
Info,
AlertCircle,
} from "lucide-react";
import { TrendingUp } from "lucide-react";
import {
LineChart,
Line,
@@ -36,13 +21,7 @@ import {
Legend,
ReferenceLine,
} from "recharts";
import {
TIME_RANGES,
GROUP_BY_OPTIONS,
formatDateForInput,
parseDateFromInput,
} from "@/lib/dashboard/constants";
import { Checkbox } from "@/components/ui/checkbox";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import {
Table,
TableHeader,
@@ -51,75 +30,26 @@ import {
TableBody,
TableCell,
} from "@/components/ui/table";
import { debounce } from "lodash";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
CARD_STYLES,
TYPOGRAPHY,
METRIC_COLORS as SHARED_METRIC_COLORS,
} from "@/lib/dashboard/designTokens";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardChartTooltip,
ChartSkeleton,
TableSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
PAYMENT_REFUNDED: "R7XUYh",
};
// Map current periods to their previous equivalents
const PREVIOUS_PERIOD_MAP = {
today: "yesterday",
thisWeek: "lastWeek",
thisMonth: "lastMonth",
last7days: "previous7days",
last30days: "previous30days",
last90days: "previous90days",
yesterday: "twoDaysAgo",
};
// Add helper function to calculate previous period dates
const calculatePreviousPeriodDates = (timeRange, startDate, endDate) => {
if (timeRange && timeRange !== "custom") {
return {
timeRange: PREVIOUS_PERIOD_MAP[timeRange],
};
} else if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
const duration = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
return {
startDate: prevStart.toISOString(),
endDate: prevEnd.toISOString(),
};
}
return null;
};
// Move formatCurrency to top and export it
export const formatCurrency = (value, minimumFractionDigits = 0) => {
if (!value || isNaN(value)) return "$0";
@@ -131,60 +61,23 @@ export const formatCurrency = (value, minimumFractionDigits = 0) => {
}).format(value);
};
// Add a helper function for percentage formatting
const formatPercentage = (value) => {
if (typeof value !== "number") return "0%";
return `${Math.abs(Math.round(value))}%`;
// Sales chart tooltip formatter - formats revenue/AOV as currency, others as numbers
const salesValueFormatter = (value, name) => {
const nameLower = (name || "").toLowerCase();
if (nameLower.includes('revenue') || nameLower.includes('order value') || nameLower.includes('average')) {
return formatCurrency(value);
}
return typeof value === 'number' ? value.toLocaleString() : value;
};
// Add color mapping for metrics - using shared tokens where applicable
const METRIC_COLORS = {
revenue: SHARED_METRIC_COLORS.aov, // Purple for revenue
orders: SHARED_METRIC_COLORS.revenue, // Emerald for orders
avgOrderValue: "#9333ea", // Deep purple for AOV
movingAverage: SHARED_METRIC_COLORS.comparison, // Amber for moving average
prevRevenue: SHARED_METRIC_COLORS.expense, // Orange for prev revenue
prevOrders: SHARED_METRIC_COLORS.secondary, // Cyan for prev orders
prevAvgOrderValue: SHARED_METRIC_COLORS.comparison, // Amber for prev AOV
};
// Export CustomTooltip
export const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
// Sales chart label formatter - formats timestamp as readable date
const salesLabelFormatter = (label) => {
const date = new Date(label);
const formattedDate = date.toLocaleDateString("en-US", {
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
<div className={TOOLTIP_STYLES.content}>
{payload.map((entry, index) => {
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
? formatCurrency(entry.value)
: entry.value.toLocaleString();
return (
<div key={index} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>{value}</span>
</div>
);
})}
</div>
</div>
);
}
return null;
};
const calculate7DayAverage = (data) => {
@@ -434,18 +327,9 @@ SummaryStats.displayName = "SummaryStats";
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
const SkeletonStats = () => (
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
{[...Array(4)].map((_, i) => (
<Card key={i} className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" />
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardContent>
</Card>
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
</div>
);
@@ -565,18 +449,27 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
: 0;
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className={TYPOGRAPHY.sectionTitle}>
{title}
</CardTitle>
</div>
<div className="flex items-center gap-2">
{!error && (
// Time selector for DashboardSectionHeader
const timeSelector = (
<Select
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
// Actions (Details dialog) for DashboardSectionHeader
const headerActions = !error ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
@@ -768,28 +661,20 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</div>
</DialogContent>
</Dialog>
)}
<Select
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null;
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title={title}
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Show stats only if not in error state */}
{!error &&
(loading ? (
{!error && (
loading ? (
<SkeletonStats />
) : (
<SummaryStats
@@ -797,11 +682,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
projection={projection}
projectionLoading={projectionLoading}
/>
))}
)
)}
{/* Show metric toggles only if not in error state */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.revenue ? "default" : "outline"}
@@ -876,10 +762,6 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? (
<div className="space-y-6">
<ChartSkeleton height="default" withCard={false} />
@@ -927,7 +809,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
<Legend />
<ReferenceLine
y={averageRevenue}

View File

@@ -6,15 +6,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
@@ -22,6 +13,7 @@ import {
DashboardSectionHeader,
DashboardErrorState,
DashboardBadge,
DashboardTable,
ChartSkeleton,
TableSkeleton,
SimpleTooltip,
@@ -166,14 +158,14 @@ const WinbackFeed = ({ responses }) => (
</div>
<div className="flex flex-wrap gap-1">
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
<DashboardBadge key={idx} variant="default" size="sm">
{label}
</Badge>
</DashboardBadge>
))}
{reasonsAnswer?.choices?.other && (
<Badge variant="outline" className="text-xs">
<DashboardBadge variant="purple" size="sm">
{reasonsAnswer.choices.other}
</Badge>
</DashboardBadge>
)}
</div>
{feedbackAnswer?.text && (
@@ -325,6 +317,28 @@ const TypeformDashboard = () => {
const newestResponse = getNewestResponse();
// Column definitions for reasons table
const reasonsColumns = [
{
key: "reason",
header: "Reason",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "count",
header: "Count",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "percentage",
header: "%",
align: "right",
width: "w-[80px]",
render: (value) => <span className="text-muted-foreground">{value}%</span>,
},
];
if (error) {
return (
<Card className={`h-full ${CARD_STYLES.base}`}>
@@ -554,41 +568,13 @@ const TypeformDashboard = () => {
<Card className="bg-card h-full">
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
<CardContent>
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-medium text-foreground">
Reason
</TableHead>
<TableHead className="text-right font-medium text-foreground">
Count
</TableHead>
<TableHead className="text-right w-[80px] font-medium text-foreground">
%
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.winback.reasons.map((reason, index) => (
<TableRow
key={index}
className="hover:bg-muted/50 transition-colors"
>
<TableCell className="font-medium text-foreground">
{reason.reason}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.count}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.percentage}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DashboardTable
columns={reasonsColumns}
data={metrics?.winback?.reasons || []}
getRowKey={(reason, index) => `${reason.reason}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -8,14 +8,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
PieChart,
Pie,
@@ -26,6 +18,8 @@ import {
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardTable,
DashboardChartTooltip,
TableSkeleton,
ChartSkeleton,
TOOLTIP_STYLES,
@@ -104,10 +98,7 @@ export const UserBehaviorDashboard = () => {
throw new Error("Invalid response structure");
}
// Handle both data structures
const rawData = result.data?.data || result.data;
// Try to access the data differently based on the structure
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
@@ -133,6 +124,74 @@ export const UserBehaviorDashboard = () => {
fetchData();
}, [timeRange]);
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
// Column definitions for pages table
const pagesColumns = [
{
key: "path",
header: "Page Path",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "pageViews",
header: "Views",
align: "right",
render: (value) => <span className="text-muted-foreground">{value.toLocaleString()}</span>,
},
{
key: "bounceRate",
header: "Bounce Rate",
align: "right",
render: (value) => <span className="text-muted-foreground">{value.toFixed(1)}%</span>,
},
{
key: "avgSessionDuration",
header: "Avg. Duration",
align: "right",
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
},
];
// Column definitions for sources table
const sourcesColumns = [
{
key: "source",
header: "Source",
width: "w-[35%] min-w-[120px]",
render: (value) => <span className="font-medium text-foreground break-words max-w-[160px]">{value}</span>,
},
{
key: "sessions",
header: "Sessions",
align: "right",
width: "w-[20%] min-w-[80px]",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversions",
header: "Conv.",
align: "right",
width: "w-[20%] min-w-[80px]",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversionRate",
header: "Conv. Rate",
align: "right",
width: "w-[25%] min-w-[80px]",
render: (_, row) => (
<span className="text-muted-foreground whitespace-nowrap">
{((row.conversions / row.sessions) * 100).toFixed(1)}%
</span>
),
},
];
if (loading) {
return (
<Card className={`${CARD_STYLES.base} h-full`}>
@@ -180,41 +239,33 @@ export const UserBehaviorDashboard = () => {
0
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
const color = COLORS[data.device.toLowerCase()];
// Custom item renderer for the device tooltip - renders both Views and Sessions rows
const deviceTooltipRenderer = (item, index) => {
if (index > 0) return null; // Only render for the first item (pie chart sends single slice)
const deviceData = item.payload;
const color = COLORS[deviceData.device.toLowerCase()];
const viewsPercentage = ((deviceData.pageViews / totalViews) * 100).toFixed(1);
const sessionsPercentage = ((deviceData.sessions / totalSessions) * 100).toFixed(1);
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{data.device}</p>
<div className={TOOLTIP_STYLES.content}>
<>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
<span className={TOOLTIP_STYLES.name}>Views</span>
</div>
<span className={TOOLTIP_STYLES.value}>{data.pageViews.toLocaleString()} ({percentage}%)</span>
<span className={TOOLTIP_STYLES.value}>{deviceData.pageViews.toLocaleString()} ({viewsPercentage}%)</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
<span className={TOOLTIP_STYLES.name}>Sessions</span>
</div>
<span className={TOOLTIP_STYLES.value}>{data.sessions.toLocaleString()} ({sessionPercentage}%)</span>
</div>
</div>
<span className={TOOLTIP_STYLES.value}>{deviceData.sessions.toLocaleString()} ({sessionsPercentage}%)</span>
</div>
</>
);
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
@@ -249,78 +300,27 @@ export const UserBehaviorDashboard = () => {
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground">Page Path</TableHead>
<TableHead className="text-right text-foreground">Views</TableHead>
<TableHead className="text-right text-foreground">Bounce Rate</TableHead>
<TableHead className="text-right text-foreground">Avg. Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.pageData?.pageData.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground">
{page.path}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.pageViews.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.bounceRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatDuration(page.avgSessionDuration)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TabsContent value="pages" className="mt-4 space-y-2">
<DashboardTable
columns={pagesColumns}
data={data?.data?.pageData?.pageData || []}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground w-[35%] min-w-[120px]">Source</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Sessions</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Conv.</TableHead>
<TableHead className="text-right text-foreground w-[25%] min-w-[80px]">Conv. Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.sourceData?.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground break-words max-w-[160px]">
{source.source}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.sessions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.conversions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{((source.conversions / source.sessions) * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TabsContent value="sources" className="mt-4 space-y-2">
<DashboardTable
columns={sourcesColumns}
data={data?.data?.sourceData || []}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent
value="devices"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<TabsContent value="devices" className="mt-4 space-y-2">
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
@@ -343,7 +343,14 @@ export const UserBehaviorDashboard = () => {
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Tooltip
content={
<DashboardChartTooltip
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
itemRenderer={deviceTooltipRenderer}
/>
}
/>
</PieChart>
</ResponsiveContainer>
</div>

View File

@@ -210,6 +210,8 @@ export const ChartSkeleton: React.FC<ChartSkeletonProps> = ({
// TABLE SKELETON
// =============================================================================
export type TableSkeletonVariant = "simple" | "detailed";
export interface TableSkeletonProps {
/** Number of rows to show */
rows?: number;
@@ -227,6 +229,14 @@ export interface TableSkeletonProps {
scrollable?: boolean;
/** Max height for scrollable (uses SCROLL_STYLES keys) */
maxHeight?: "sm" | "md" | "lg" | "xl";
/**
* Cell layout variant:
* - "simple": single-line cells (default)
* - "detailed": multi-line cells with icon in first column and value+sublabel in others
*/
variant?: TableSkeletonVariant;
/** Show icon placeholder in first column (only for detailed variant) */
hasIcon?: boolean;
}
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
@@ -238,6 +248,8 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
className,
scrollable = false,
maxHeight = "md",
variant = "simple",
hasIcon = true,
}) => {
const columnCount = Array.isArray(columns) ? columns.length : columns;
const columnWidths = Array.isArray(columns)
@@ -245,13 +257,50 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
: Array(columnCount).fill("w-24");
const colors = COLOR_VARIANT_CLASSES[colorVariant];
// Simple variant - single line cells
const renderSimpleCell = (colIndex: number) => (
<Skeleton
className={cn(
"h-4",
colors.skeleton,
colIndex === 0 ? "w-32" : columnWidths[colIndex]
)}
/>
);
// Detailed variant - first column has icon + stacked text, others have value + sublabel
const renderDetailedFirstCell = () => (
<div className="flex items-center gap-2">
{hasIcon && <Skeleton className={cn("h-4 w-4", colors.skeleton)} />}
<div className="space-y-2">
<Skeleton className={cn("h-4 w-48", colors.skeleton)} />
<Skeleton className={cn("h-3 w-64", colors.skeleton)} />
<Skeleton className={cn("h-3 w-32", colors.skeleton)} />
</div>
</div>
);
const renderDetailedMetricCell = () => (
<div className="flex flex-col items-center gap-1">
<Skeleton className={cn("h-4 w-16", colors.skeleton)} />
<Skeleton className={cn("h-3 w-24", colors.skeleton)} />
</div>
);
const tableContent = (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, i) => (
<TableHead key={i}>
<Skeleton className={cn("h-4", colors.skeleton, columnWidths[i])} />
<TableHead key={i} className={i === 0 ? "text-left" : "text-center"}>
<Skeleton
className={cn(
variant === "detailed" ? "h-8" : "h-4",
colors.skeleton,
i === 0 ? "w-24" : "w-20",
i !== 0 && "mx-auto"
)}
/>
</TableHead>
))}
</TableRow>
@@ -260,14 +309,12 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
{Array.from({ length: rows }).map((_, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors">
{Array.from({ length: columnCount }).map((_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton
className={cn(
"h-4",
colors.skeleton,
colIndex === 0 ? "w-32" : columnWidths[colIndex]
<TableCell key={colIndex} className={colIndex !== 0 ? "text-center" : ""}>
{variant === "detailed" ? (
colIndex === 0 ? renderDetailedFirstCell() : renderDetailedMetricCell()
) : (
renderSimpleCell(colIndex)
)}
/>
</TableCell>
))}
</TableRow>

View File

@@ -55,6 +55,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import {
@@ -86,6 +87,17 @@ export interface TableColumn<T = Record<string, unknown>> {
className?: string;
/** Whether to use tabular-nums for numeric values */
numeric?: boolean;
/** Whether this column is sortable */
sortable?: boolean;
/** Custom sort key if different from column key */
sortKey?: string;
}
export type SortDirection = "asc" | "desc";
export interface SortConfig {
key: string;
direction: SortDirection;
}
export interface DashboardTableProps<T = Record<string, unknown>> {
@@ -119,6 +131,10 @@ export interface DashboardTableProps<T = Record<string, unknown>> {
className?: string;
/** Additional className for the table element */
tableClassName?: string;
/** Current sort configuration (for controlled sorting) */
sortConfig?: SortConfig;
/** Callback when a sortable column header is clicked */
onSort?: (key: string, direction: SortDirection) => void;
}
// =============================================================================
@@ -159,10 +175,50 @@ export function DashboardTable<T extends Record<string, unknown>>({
bordered = false,
className,
tableClassName,
sortConfig,
onSort,
}: DashboardTableProps<T>): React.ReactElement {
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
// Handle sort click - toggles direction or sets new sort key
const handleSortClick = (col: TableColumn<T>) => {
if (!onSort || !col.sortable) return;
const sortKey = col.sortKey || col.key;
const currentDirection = sortConfig?.key === sortKey ? sortConfig.direction : null;
const newDirection: SortDirection = currentDirection === "desc" ? "asc" : "desc";
onSort(sortKey, newDirection);
};
// Render header cell content - either plain text or sortable button
const renderHeaderContent = (col: TableColumn<T>) => {
if (!col.sortable || !onSort) {
return col.header;
}
const sortKey = col.sortKey || col.key;
const isActive = sortConfig?.key === sortKey;
return (
<Button
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={() => handleSortClick(col)}
className={cn(
"h-8 font-medium",
col.align === "center" && "w-full justify-center",
col.align === "right" && "w-full justify-end",
col.align === "left" && "justify-start",
!col.align && "justify-start"
)}
>
{col.header}
</Button>
);
};
// Loading skeleton
if (loading) {
return (
@@ -241,13 +297,14 @@ export function DashboardTable<T extends Record<string, unknown>>({
key={col.key}
className={cn(
TABLE_STYLES.headerCell,
paddingClass,
// Reduce padding when sortable since button has its own padding
col.sortable && onSort ? "p-1" : paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.width,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
{col.header}
{renderHeaderContent(col)}
</TableHead>
))}
</TableRow>

View File

@@ -72,6 +72,8 @@ export {
type CellAlignment,
type SimpleTableProps,
type SimpleTableRow,
type SortDirection,
type SortConfig,
} from "./DashboardTable";
// =============================================================================
@@ -133,6 +135,7 @@ export {
// Types
type ChartSkeletonProps,
type TableSkeletonProps,
type TableSkeletonVariant,
type StatCardSkeletonProps,
type GridSkeletonProps,
type ListSkeletonProps,

File diff suppressed because one or more lines are too long