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, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -34,10 +26,9 @@ import {
DashboardStatCardSkeleton, DashboardStatCardSkeleton,
DashboardSectionHeader, DashboardSectionHeader,
DashboardErrorState, DashboardErrorState,
DashboardTable,
ChartSkeleton, ChartSkeleton,
TableSkeleton,
CARD_STYLES, CARD_STYLES,
SCROLL_STYLES,
METRIC_COLORS, METRIC_COLORS,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
import { Phone, Clock, Zap, Timer } from "lucide-react"; import { Phone, Clock, Zap, Timer } from "lucide-react";
@@ -73,47 +64,6 @@ const formatDuration = (seconds) => {
return `${minutes}m ${remainingSeconds}s`; 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 AircallDashboard = () => {
const [timeRange, setTimeRange] = useState("last7days"); const [timeRange, setTimeRange] = useState("last7days");
const [metrics, setMetrics] = useState(null); 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 () => { const fetchData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -363,16 +365,17 @@ const AircallDashboard = () => {
<Card className={CARD_STYLES.base}> <Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact /> <DashboardSectionHeader title="Agent Performance" compact />
<CardContent> <CardContent>
{isLoading ? ( <DashboardTable
<TableSkeleton rows={5} columns={5} /> columns={agentColumns}
) : ( data={sortedAgents}
<div className={SCROLL_STYLES.md}> loading={isLoading}
<AgentPerformanceTable skeletonRows={5}
agents={sortedAgents} getRowKey={(agent) => agent.id}
sortConfig={agentSort}
onSort={(key, direction) => setAgentSort({ key, direction })} onSort={(key, direction) => setAgentSort({ key, direction })}
maxHeight="md"
compact
/> />
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -380,32 +383,15 @@ const AircallDashboard = () => {
<Card className={CARD_STYLES.base}> <Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Missed Call Reasons" compact /> <DashboardSectionHeader title="Missed Call Reasons" compact />
<CardContent> <CardContent>
{isLoading ? ( <DashboardTable
<TableSkeleton rows={5} columns={2} /> columns={missedReasonsColumns}
) : ( data={chartData.missedReasons}
<div className={SCROLL_STYLES.md}> loading={isLoading}
<Table> skeletonRows={5}
<TableHeader> getRowKey={(reason, index) => `${reason.reason}-${index}`}
<TableRow className="hover:bg-transparent"> maxHeight="md"
<TableHead className="font-medium">Reason</TableHead> compact
<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>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

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

View File

@@ -1,11 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { import { Card, CardContent } from "@/components/ui/card";
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -42,7 +37,6 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import { Skeleton } from "@/components/ui/skeleton";
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react"; import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
import PeriodSelectionPopover, { import PeriodSelectionPopover, {
type QuickPreset, type QuickPreset,
@@ -50,8 +44,10 @@ import PeriodSelectionPopover, {
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod"; import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardSectionHeader,
DashboardStatCard, DashboardStatCard,
DashboardStatCardSkeleton, DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState, DashboardEmptyState,
DashboardErrorState, DashboardErrorState,
TOOLTIP_STYLES, TOOLTIP_STYLES,
@@ -1102,20 +1098,9 @@ const FinancialOverview = () => {
}; };
return ( // Header actions: Details dialog and Period selector
<Card className={`w-full ${CARD_STYLES.base}`}> const headerActions = !error ? (
<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 && (
<> <>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}> <Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
@@ -1239,24 +1224,30 @@ const FinancialOverview = () => {
onQuickSelect={handleQuickPeriod} onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult} onApplyResult={handleNaturalLanguageResult}
/> />
</> </>
)} ) : null;
</div>
</div>
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 */} {/* Show stats only if not in error state */}
{!error && {!error && (
(loading ? ( loading ? (
<SkeletonStats /> <SkeletonStats />
) : ( ) : (
cards.length > 0 && <FinancialStatGrid cards={cards} /> cards.length > 0 && <FinancialStatGrid cards={cards} />
))} )
)}
{/* Show metric toggles only if not in error state */} {/* Show metric toggles only if not in error state */}
{!error && ( {!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"> <div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => ( {SERIES_DEFINITIONS.map((series) => (
<Button <Button
@@ -1297,12 +1288,9 @@ const FinancialOverview = () => {
</div> </div>
</div> </div>
)} )}
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? ( {loading ? (
<div className="space-y-6"> <div className="space-y-6">
<SkeletonChart /> <SkeletonChartSection />
</div> </div>
) : error ? ( ) : error ? (
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" /> <DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
@@ -1496,54 +1484,9 @@ function SkeletonStats() {
); );
} }
function SkeletonChart() { function SkeletonChartSection() {
return ( return (
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}> <ChartSkeleton type="area" height="default" withCard={false} />
<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>
); );
} }

View File

@@ -7,15 +7,6 @@ import {
SelectItem, SelectItem,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { import {
Mail, Mail,
Send, Send,
@@ -34,6 +25,7 @@ import {
DashboardStatCardSkeleton, DashboardStatCardSkeleton,
DashboardSectionHeader, DashboardSectionHeader,
DashboardErrorState, DashboardErrorState,
DashboardTable,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
const TIME_RANGES = { const TIME_RANGES = {
@@ -51,14 +43,12 @@ const formatDuration = (seconds) => {
}; };
const getDateRange = (days) => { const getDateRange = (days) => {
// Create date in Eastern Time
const now = new Date(); const now = new Date();
const easternTime = new Date( const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" }) now.toLocaleString("en-US", { timeZone: "America/New_York" })
); );
if (days === "today") { if (days === "today") {
// For today, set the range to be the current day in Eastern Time
const start = new Date(easternTime); const start = new Date(easternTime);
start.setHours(0, 0, 0, 0); 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); const end = new Date(easternTime);
end.setHours(23, 59, 59, 999); end.setHours(23, 59, 59, 999);
@@ -85,28 +74,26 @@ const getDateRange = (days) => {
}; };
}; };
const TableSkeleton = () => ( // Trend cell component with arrow and color
<Table> const TrendCell = ({ delta }) => {
<TableHeader> if (delta === 0) return null;
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead> const isPositive = delta > 0;
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead> const colorClass = isPositive
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead> ? "text-green-600 dark:text-green-500"
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead> : "text-red-600 dark:text-red-500";
</TableRow>
</TableHeader> return (
<TableBody> <div className={`flex items-center justify-end gap-0.5 ${colorClass}`}>
{[...Array(5)].map((_, i) => ( {isPositive ? (
<TableRow key={i} className="dark:border-gray-800"> <ArrowUp className="w-3 h-3" />
<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> <ArrowDown className="w-3 h-3" />
<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> <span>{Math.abs(delta)}%</span>
</TableRow> </div>
))}
</TableBody>
</Table>
); );
};
const GorgiasOverview = () => { const GorgiasOverview = () => {
const [timeRange, setTimeRange] = useState("7"); const [timeRange, setTimeRange] = useState("7");
@@ -153,7 +140,6 @@ const GorgiasOverview = () => {
useEffect(() => { useEffect(() => {
loadStats(); loadStats();
// Set up auto-refresh every 5 minutes
const interval = setInterval(loadStats, 5 * 60 * 1000); const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadStats]); }, [loadStats]);
@@ -183,21 +169,79 @@ const GorgiasOverview = () => {
}, {}); }, {});
// Process channel data // Process channel data
const channels = data.channels?.map(line => ({ const channels = (data.channels?.map(line => ({
name: line[0]?.value || '', name: line[0]?.value || '',
total: line[1]?.value || 0, total: line[1]?.value || 0,
percentage: line[2]?.value || 0, percentage: line[2]?.value || 0,
delta: line[3]?.value || 0 delta: line[3]?.value || 0
})) || []; })) || []).sort((a, b) => b.total - a.total);
// Process agent data // Process agent data
const agents = data.agents?.map(line => ({ const agents = (data.agents?.map(line => ({
name: line[0]?.value || '', name: line[0]?.value || '',
closed: line[1]?.value || 0, closed: line[1]?.value || 0,
rating: line[2]?.value, rating: line[2]?.value,
percentage: line[3]?.value || 0, percentage: line[3]?.value || 0,
delta: line[4]?.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) { if (error) {
return ( return (
@@ -245,7 +289,6 @@ const GorgiasOverview = () => {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Message & Response Metrics */}
{loading ? ( {loading ? (
[...Array(7)].map((_, i) => ( [...Array(7)].map((_, i) => (
<DashboardStatCardSkeleton key={i} size="compact" /> <DashboardStatCardSkeleton key={i} size="compact" />
@@ -341,120 +384,32 @@ const GorgiasOverview = () => {
{/* Channel Distribution */} {/* Channel Distribution */}
<Card className={CARD_STYLES.base}> <Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" /> <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"> <CardContent>
{loading ? ( <DashboardTable
<TableSkeleton /> columns={channelColumns}
) : ( data={channels}
<Table> loading={loading}
<TableHeader> skeletonRows={5}
<TableRow className="dark:border-gray-800"> getRowKey={(channel, index) => `${channel.name}-${index}`}
<TableHead className="text-left font-medium text-foreground">Channel</TableHead> maxHeight="md"
<TableHead className="text-right font-medium text-foreground">Total</TableHead> compact
<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> </CardContent>
</Card> </Card>
{/* Agent Performance */} {/* Agent Performance */}
<Card className={CARD_STYLES.base}> <Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" /> <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"> <CardContent>
{loading ? ( <DashboardTable
<TableSkeleton /> columns={agentColumns}
) : ( data={agents}
<Table> loading={loading}
<TableHeader> skeletonRows={5}
<TableRow className="dark:border-gray-800"> getRowKey={(agent, index) => `${agent.name}-${index}`}
<TableHead className="text-left font-medium text-foreground">Agent</TableHead> maxHeight="md"
<TableHead className="text-right font-medium text-foreground">Closed</TableHead> compact
<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> </CardContent>
</Card> </Card>
</div> </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 { Card, CardContent } from "@/components/ui/card";
import { import {
Tooltip, Tooltip,
@@ -17,11 +17,12 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants"; import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, BookOpen } from "lucide-react"; import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardSectionHeader, DashboardSectionHeader,
DashboardErrorState, DashboardErrorState,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
// Helper functions for formatting // Helper functions for formatting
@@ -41,83 +42,8 @@ const formatCurrency = (value) => {
}).format(value); }).format(value);
}; };
// Loading skeleton component // MetricCell content component for displaying campaign metrics (returns content, not <td>)
const TableSkeleton = () => ( const MetricCellContent = ({
<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 = ({
value, value,
count, count,
isMonetary = false, isMonetary = false,
@@ -128,15 +54,15 @@ const MetricCell = ({
}) => { }) => {
if (isSMS && hideForSMS) { if (isSMS && hideForSMS) {
return ( 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-lg font-semibold">N/A</div>
<div className="text-muted-foreground text-sm">-</div> <div className="text-muted-foreground text-sm">-</div>
</td> </div>
); );
} }
return ( return (
<td className="p-2 text-center"> <div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold"> <div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)} {isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</div> </div>
@@ -146,7 +72,56 @@ const MetricCell = ({
totalRecipients > 0 && totalRecipients > 0 &&
` (${((count / totalRecipients) * 100).toFixed(2)}%)`} ` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
</div> </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 [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true }); const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days"); const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
const [sortConfig, setSortConfig] = useState({ const [sortConfig, setSortConfig] = useState({
@@ -162,11 +136,8 @@ const KlaviyoCampaigns = ({ className }) => {
direction: "desc", direction: "desc",
}); });
const handleSort = (key) => { const handleSort = (key, direction) => {
setSortConfig((prev) => ({ setSortConfig({ key, direction });
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
}; };
const fetchCampaigns = async () => { const fetchCampaigns = async () => {
@@ -193,9 +164,9 @@ const KlaviyoCampaigns = ({ className }) => {
useEffect(() => { useEffect(() => {
fetchCampaigns(); fetchCampaigns();
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes const interval = setInterval(fetchCampaigns, 10 * 60 * 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [selectedTimeRange]); // Only refresh when time range changes }, [selectedTimeRange]);
// Sort campaigns // Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => { 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( const filteredCampaigns = sortedCampaigns.filter(
(campaign) => { (campaign) => {
const isBlog = campaign?.name?.includes("_Blog"); const isBlog = campaign?.name?.includes("_Blog");
const channelType = isBlog ? "blog" : campaign?.channel; const channelType = isBlog ? "blog" : campaign?.channel;
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) && return selectedChannels[channelType];
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) { if (isLoading) {
return ( return (
<Card className={`h-full ${CARD_STYLES.base}`}> <Card className={`h-full ${CARD_STYLES.base}`}>
@@ -240,7 +295,7 @@ const KlaviyoCampaigns = ({ className }) => {
timeSelector={<div className="w-[130px]" />} timeSelector={<div className="w-[130px]" />}
/> />
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<TableSkeleton /> <TableSkeleton rows={15} columns={6} variant="detailed" />
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -316,150 +371,17 @@ const KlaviyoCampaigns = ({ className }) => {
</Select> </Select>
} }
/> />
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="pl-4 mb-4">
<table className="w-full"> <DashboardTable
<thead> columns={columns}
<tr> data={filteredCampaigns}
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground"> getRowKey={(campaign) => campaign.id}
<Button sortConfig={sortConfig}
variant="ghost" onSort={handleSort}
onClick={() => handleSort("send_time")} maxHeight="md"
className="w-full justify-start h-8" stickyHeader
> bordered
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'}
/> />
<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> </CardContent>
</Card> </Card>
); );

View File

@@ -19,14 +19,14 @@ import {
ShoppingCart, ShoppingCart,
MessageCircle, MessageCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardStatCard, DashboardStatCard,
DashboardStatCardSkeleton, DashboardStatCardSkeleton,
DashboardSectionHeader, DashboardSectionHeader,
DashboardErrorState, DashboardErrorState,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
// Helper functions for formatting // Helper functions for formatting
@@ -48,8 +48,8 @@ const formatNumber = (value, decimalPlaces = 0) => {
const formatPercent = (value, decimalPlaces = 2) => const formatPercent = (value, decimalPlaces = 2) =>
`${(value || 0).toFixed(decimalPlaces)}%`; `${(value || 0).toFixed(decimalPlaces)}%`;
// MetricCell content component (returns content, not <td>)
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => { const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
const formattedValue = isMonetary const formattedValue = isMonetary
? formatCurrency(value, decimalPlaces) ? formatCurrency(value, decimalPlaces)
: isPercentage : isPercentage
@@ -57,7 +57,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
: formatNumber(value, decimalPlaces); : formatNumber(value, decimalPlaces);
return ( 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"> <div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{formattedValue} {formattedValue}
</div> </div>
@@ -66,7 +66,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
{label || sublabel} {label || sublabel}
</div> </div>
)} )}
</td> </div>
); );
}; };
@@ -84,16 +84,27 @@ const getActionValue = (campaign, actionType) => {
return 0; return 0;
}; };
const CampaignName = ({ name }) => { const CampaignNameCell = ({ campaign }) => {
if (name.startsWith("Instagram post: ")) { const name = campaign.name;
const isInstagram = name.startsWith("Instagram post: ");
return ( 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"> <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> <span>{name.replace("Instagram post: ", "")}</span>
</div> </div>
) : (
<span>{name}</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
); );
}
return <span>{name}</span>;
}; };
const getObjectiveAction = (campaignObjective) => { const getObjectiveAction = (campaignObjective) => {
@@ -138,7 +149,6 @@ const processMetrics = (campaign) => {
const cpm = parseFloat(insights.cpm || 0); const cpm = parseFloat(insights.cpm || 0);
const frequency = parseFloat(insights.frequency || 0); const frequency = parseFloat(insights.frequency || 0);
// Purchase value and total purchases
const purchaseValue = (insights.action_values || []) const purchaseValue = (insights.action_values || [])
.filter(({ action_type }) => action_type === "purchase") .filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0); .reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
@@ -147,7 +157,6 @@ const processMetrics = (campaign) => {
.filter(({ action_type }) => action_type === "purchase") .filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseInt(value || 0), 0); .reduce((sum, { value }) => sum + parseInt(value || 0), 0);
// Aggregate unique actions
const actionMap = new Map(); const actionMap = new Map();
(insights.actions || []).forEach(({ action_type, value }) => { (insights.actions || []).forEach(({ action_type, value }) => {
const currentValue = actionMap.get(action_type) || 0; const currentValue = actionMap.get(action_type) || 0;
@@ -159,13 +168,11 @@ const processMetrics = (campaign) => {
value, value,
})); }));
// Map of cost per action
const costPerActionMap = new Map(); const costPerActionMap = new Map();
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => { (insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
costPerActionMap.set(action_type, parseFloat(value || 0)); costPerActionMap.set(action_type, parseFloat(value || 0));
}); });
// Total post engagements
const totalPostEngagements = actionMap.get("post_engagement") || 0; const totalPostEngagements = actionMap.get("post_engagement") || 0;
return { return {
@@ -190,7 +197,6 @@ const processCampaignData = (campaign) => {
const budget = calculateBudget(campaign); const budget = calculateBudget(campaign);
const { action_type, label } = getObjectiveAction(campaign.objective); const { action_type, label } = getObjectiveAction(campaign.objective);
// Get cost per result from costPerActionMap
const costPerResult = metrics.costPerActionMap.get(action_type) || 0; const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
return { 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 MetaCampaigns = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -262,30 +225,24 @@ const MetaCampaigns = () => {
direction: "desc", direction: "desc",
}); });
const handleSort = (key) => { const handleSort = (key, direction) => {
setSortConfig((prev) => ({ setSortConfig({ key, direction });
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
}; };
const computeDateRange = (timeframe) => { const computeDateRange = (timeframe) => {
// Create date in Eastern Time
const now = new Date(); const now = new Date();
const easternTime = new Date( const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" }) 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; let sinceDate, untilDate;
if (timeframe === "today") { if (timeframe === "today") {
// For today, both dates should be the current date in Eastern Time
sinceDate = untilDate = new Date(easternTime); sinceDate = untilDate = new Date(easternTime);
} else { } else {
// For other periods, calculate the date range
untilDate = new Date(easternTime); untilDate = new Date(easternTime);
untilDate.setDate(untilDate.getDate() - 1); // Yesterday untilDate.setDate(untilDate.getDate() - 1);
sinceDate = new Date(untilDate); sinceDate = new Date(untilDate);
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1); sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
@@ -315,7 +272,6 @@ const MetaCampaigns = () => {
accountInsights.json() accountInsights.json()
]); ]);
// Process campaigns with the new processing logic
const processedCampaigns = campaignsJson.map(processCampaignData); const processedCampaigns = campaignsJson.map(processCampaignData);
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0); const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
setCampaigns(activeCampaigns); setCampaigns(activeCampaigns);
@@ -367,7 +323,6 @@ const MetaCampaigns = () => {
switch (sortConfig.key) { switch (sortConfig.key) {
case "date": case "date":
// Add date sorting using campaign ID (Meta IDs are chronological)
return direction * (parseInt(b.id) - parseInt(a.id)); return direction * (parseInt(b.id) - parseInt(a.id));
case "spend": case "spend":
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0)); 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) { if (loading) {
return ( return (
<Card className={`h-full ${CARD_STYLES.base}`}> <Card className={`h-full ${CARD_STYLES.base}`}>
@@ -407,7 +474,7 @@ const MetaCampaigns = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<SkeletonTable /> <TableSkeleton rows={5} columns={9} variant="detailed" />
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -535,162 +602,17 @@ const MetaCampaigns = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="pl-4 mb-4">
<table className="w-full"> <DashboardTable
<thead> columns={columns}
<tr className="border-b border-border/50"> data={sortedCampaigns}
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground"> getRowKey={(campaign) => campaign.id}
<Button sortConfig={sortConfig}
variant="ghost" onSort={handleSort}
className="pl-0 justify-start w-full h-8" maxHeight="md"
onClick={() => handleSort("date")} stickyHeader
> bordered
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"
}
/> />
<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> </CardContent>
</Card> </Card>
); );

View File

@@ -15,14 +15,6 @@ import {
TooltipProvider, TooltipProvider,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { format } from "date-fns";
// Import shared components and tokens // Import shared components and tokens
@@ -30,13 +22,13 @@ import {
DashboardChartTooltip, DashboardChartTooltip,
DashboardSectionHeader, DashboardSectionHeader,
DashboardStatCard, DashboardStatCard,
DashboardTable,
StatCardSkeleton, StatCardSkeleton,
ChartSkeleton, ChartSkeleton,
TableSkeleton, TableSkeleton,
DashboardErrorState, DashboardErrorState,
CARD_STYLES, CARD_STYLES,
TYPOGRAPHY, TYPOGRAPHY,
SCROLL_STYLES,
METRIC_COLORS, METRIC_COLORS,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
@@ -344,6 +336,36 @@ export const RealtimeAnalytics = () => {
}; };
}, [isPaused]); }, [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) { if (loading && !basicData && !detailedData) {
return ( return (
<Card className={`${CARD_STYLES.base} h-full`}> <Card className={`${CARD_STYLES.base} h-full`}>
@@ -448,64 +470,28 @@ export const RealtimeAnalytics = () => {
</TabsContent> </TabsContent>
<TabsContent value="pages"> <TabsContent value="pages">
<div className={`h-[230px] ${SCROLL_STYLES.container}`}> <div className="h-[230px]">
<Table> <DashboardTable
<TableHeader> columns={pagesColumns}
<TableRow> data={detailedData.currentPages}
<TableHead className="text-foreground"> loading={loading}
Page getRowKey={(page, index) => `${page.path}-${index}`}
</TableHead> maxHeight="sm"
<TableHead className="text-right text-foreground"> compact
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> </div>
</TabsContent> </TabsContent>
<TabsContent value="sources"> <TabsContent value="sources">
<div className={`h-[230px] ${SCROLL_STYLES.container}`}> <div className="h-[230px]">
<Table> <DashboardTable
<TableHeader> columns={sourcesColumns}
<TableRow> data={detailedData.sources}
<TableHead className="text-foreground"> loading={loading}
Source getRowKey={(source, index) => `${source.source}-${index}`}
</TableHead> maxHeight="sm"
<TableHead className="text-right text-foreground"> compact
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> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -1,13 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, memo } from "react"; import React, { useState, useEffect, useCallback, memo } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { import { Card, CardContent } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -15,16 +8,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { TrendingUp } from "lucide-react";
Loader2,
TrendingUp,
TrendingDown,
Info,
AlertCircle,
} from "lucide-react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -36,13 +21,7 @@ import {
Legend, Legend,
ReferenceLine, ReferenceLine,
} from "recharts"; } from "recharts";
import { import { TIME_RANGES } from "@/lib/dashboard/constants";
TIME_RANGES,
GROUP_BY_OPTIONS,
formatDateForInput,
parseDateFromInput,
} from "@/lib/dashboard/constants";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Table, Table,
TableHeader, TableHeader,
@@ -51,75 +30,26 @@ import {
TableBody, TableBody,
TableCell, TableCell,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { debounce } from "lodash";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
CARD_STYLES,
TYPOGRAPHY,
METRIC_COLORS as SHARED_METRIC_COLORS,
} from "@/lib/dashboard/designTokens";
import { import {
DashboardSectionHeader,
DashboardStatCard, DashboardStatCard,
DashboardStatCardSkeleton,
DashboardChartTooltip,
ChartSkeleton, ChartSkeleton,
TableSkeleton,
DashboardEmptyState, DashboardEmptyState,
DashboardErrorState, DashboardErrorState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared"; } 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 // Move formatCurrency to top and export it
export const formatCurrency = (value, minimumFractionDigits = 0) => { export const formatCurrency = (value, minimumFractionDigits = 0) => {
if (!value || isNaN(value)) return "$0"; if (!value || isNaN(value)) return "$0";
@@ -131,60 +61,23 @@ export const formatCurrency = (value, minimumFractionDigits = 0) => {
}).format(value); }).format(value);
}; };
// Add a helper function for percentage formatting // Sales chart tooltip formatter - formats revenue/AOV as currency, others as numbers
const formatPercentage = (value) => { const salesValueFormatter = (value, name) => {
if (typeof value !== "number") return "0%"; const nameLower = (name || "").toLowerCase();
return `${Math.abs(Math.round(value))}%`; 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 // Sales chart label formatter - formats timestamp as readable date
const METRIC_COLORS = { const salesLabelFormatter = (label) => {
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) {
const date = new Date(label); const date = new Date(label);
const formattedDate = date.toLocaleDateString("en-US", { return date.toLocaleDateString("en-US", {
weekday: "short", weekday: "short",
month: "short", month: "short",
day: "numeric", 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) => { const calculate7DayAverage = (data) => {
@@ -434,18 +327,9 @@ SummaryStats.displayName = "SummaryStats";
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared // Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
const SkeletonStats = () => ( 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) => ( {[...Array(4)].map((_, i) => (
<Card key={i} className="bg-card"> <DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
<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>
))} ))}
</div> </div>
); );
@@ -565,18 +449,27 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length ? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
: 0; : 0;
return ( // Time selector for DashboardSectionHeader
<Card className={`w-full ${CARD_STYLES.base}`}> const timeSelector = (
<CardHeader className="p-6 pb-4"> <Select
<div className="flex flex-col space-y-2"> value={selectedTimeRange}
<div className="flex justify-between items-start"> onValueChange={handleTimeRangeChange}
<div> >
<CardTitle className={TYPOGRAPHY.sectionTitle}> <SelectTrigger className="w-[130px] h-9">
{title} <SelectValue placeholder="Select time range" />
</CardTitle> </SelectTrigger>
</div> <SelectContent>
<div className="flex items-center gap-2"> {TIME_RANGES.map((range) => (
{!error && ( <SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
// Actions (Details dialog) for DashboardSectionHeader
const headerActions = !error ? (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="h-9"> <Button variant="outline" className="h-9">
@@ -768,28 +661,20 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} ) : null;
<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>
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 */} {/* Show stats only if not in error state */}
{!error && {!error && (
(loading ? ( loading ? (
<SkeletonStats /> <SkeletonStats />
) : ( ) : (
<SummaryStats <SummaryStats
@@ -797,11 +682,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
projection={projection} projection={projection}
projectionLoading={projectionLoading} projectionLoading={projectionLoading}
/> />
))} )
)}
{/* Show metric toggles only if not in error state */} {/* Show metric toggles only if not in error state */}
{!error && ( {!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"> <div className="flex flex-wrap gap-1">
<Button <Button
variant={metrics.revenue ? "default" : "outline"} variant={metrics.revenue ? "default" : "outline"}
@@ -876,10 +762,6 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</Button> </Button>
</div> </div>
)} )}
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? ( {loading ? (
<div className="space-y-6"> <div className="space-y-6">
<ChartSkeleton height="default" withCard={false} /> <ChartSkeleton height="default" withCard={false} />
@@ -927,7 +809,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }} tick={{ fill: "currentColor" }}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
<Legend /> <Legend />
<ReferenceLine <ReferenceLine
y={averageRevenue} y={averageRevenue}

View File

@@ -6,15 +6,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns"; import { format } from "date-fns";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
@@ -22,6 +13,7 @@ import {
DashboardSectionHeader, DashboardSectionHeader,
DashboardErrorState, DashboardErrorState,
DashboardBadge, DashboardBadge,
DashboardTable,
ChartSkeleton, ChartSkeleton,
TableSkeleton, TableSkeleton,
SimpleTooltip, SimpleTooltip,
@@ -166,14 +158,14 @@ const WinbackFeed = ({ responses }) => (
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => ( {(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
<Badge key={idx} variant="secondary" className="text-xs"> <DashboardBadge key={idx} variant="default" size="sm">
{label} {label}
</Badge> </DashboardBadge>
))} ))}
{reasonsAnswer?.choices?.other && ( {reasonsAnswer?.choices?.other && (
<Badge variant="outline" className="text-xs"> <DashboardBadge variant="purple" size="sm">
{reasonsAnswer.choices.other} {reasonsAnswer.choices.other}
</Badge> </DashboardBadge>
)} )}
</div> </div>
{feedbackAnswer?.text && ( {feedbackAnswer?.text && (
@@ -325,6 +317,28 @@ const TypeformDashboard = () => {
const newestResponse = getNewestResponse(); 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) { if (error) {
return ( return (
<Card className={`h-full ${CARD_STYLES.base}`}> <Card className={`h-full ${CARD_STYLES.base}`}>
@@ -554,41 +568,13 @@ const TypeformDashboard = () => {
<Card className="bg-card h-full"> <Card className="bg-card h-full">
<DashboardSectionHeader title="Reasons for Not Ordering" compact /> <DashboardSectionHeader title="Reasons for Not Ordering" compact />
<CardContent> <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"> <DashboardTable
<Table> columns={reasonsColumns}
<TableHeader> data={metrics?.winback?.reasons || []}
<TableRow> getRowKey={(reason, index) => `${reason.reason}-${index}`}
<TableHead className="font-medium text-foreground"> maxHeight="md"
Reason compact
</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>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -8,14 +8,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
PieChart, PieChart,
Pie, Pie,
@@ -26,6 +18,8 @@ import {
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardSectionHeader, DashboardSectionHeader,
DashboardTable,
DashboardChartTooltip,
TableSkeleton, TableSkeleton,
ChartSkeleton, ChartSkeleton,
TOOLTIP_STYLES, TOOLTIP_STYLES,
@@ -104,10 +98,7 @@ export const UserBehaviorDashboard = () => {
throw new Error("Invalid response structure"); throw new Error("Invalid response structure");
} }
// Handle both data structures
const rawData = result.data?.data || result.data; 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 pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1]; const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2]; const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
@@ -133,6 +124,74 @@ export const UserBehaviorDashboard = () => {
fetchData(); fetchData();
}, [timeRange]); }, [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) { if (loading) {
return ( return (
<Card className={`${CARD_STYLES.base} h-full`}> <Card className={`${CARD_STYLES.base} h-full`}>
@@ -180,41 +239,33 @@ export const UserBehaviorDashboard = () => {
0 0
); );
const CustomTooltip = ({ active, payload }) => { // Custom item renderer for the device tooltip - renders both Views and Sessions rows
if (active && payload && payload.length) { const deviceTooltipRenderer = (item, index) => {
const data = payload[0].payload; if (index > 0) return null; // Only render for the first item (pie chart sends single slice)
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1); const deviceData = item.payload;
const color = COLORS[data.device.toLowerCase()]; const color = COLORS[deviceData.device.toLowerCase()];
const viewsPercentage = ((deviceData.pageViews / totalViews) * 100).toFixed(1);
const sessionsPercentage = ((deviceData.sessions / totalSessions) * 100).toFixed(1);
return ( 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.row}>
<div className={TOOLTIP_STYLES.rowLabel}> <div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} /> <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
<span className={TOOLTIP_STYLES.name}>Views</span> <span className={TOOLTIP_STYLES.name}>Views</span>
</div> </div>
<span className={TOOLTIP_STYLES.value}>{data.pageViews.toLocaleString()} ({percentage}%)</span> <span className={TOOLTIP_STYLES.value}>{deviceData.pageViews.toLocaleString()} ({viewsPercentage}%)</span>
</div> </div>
<div className={TOOLTIP_STYLES.row}> <div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}> <div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} /> <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
<span className={TOOLTIP_STYLES.name}>Sessions</span> <span className={TOOLTIP_STYLES.name}>Sessions</span>
</div> </div>
<span className={TOOLTIP_STYLES.value}>{data.sessions.toLocaleString()} ({sessionPercentage}%)</span> <span className={TOOLTIP_STYLES.value}>{deviceData.sessions.toLocaleString()} ({sessionsPercentage}%)</span>
</div>
</div>
</div> </div>
</>
); );
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}; };
return ( return (
@@ -249,78 +300,27 @@ export const UserBehaviorDashboard = () => {
<TabsTrigger value="devices">Device Usage</TabsTrigger> <TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList> </TabsList>
<TabsContent <TabsContent value="pages" className="mt-4 space-y-2">
value="pages" <DashboardTable
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" columns={pagesColumns}
> data={data?.data?.pageData?.pageData || []}
<Table> getRowKey={(page, index) => `${page.path}-${index}`}
<TableHeader> maxHeight="xl"
<TableRow className="dark:border-gray-800"> compact
<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> </TabsContent>
<TabsContent <TabsContent value="sources" className="mt-4 space-y-2">
value="sources" <DashboardTable
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" columns={sourcesColumns}
> data={data?.data?.sourceData || []}
<Table> getRowKey={(source, index) => `${source.source}-${index}`}
<TableHeader> maxHeight="xl"
<TableRow className="dark:border-gray-800"> compact
<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> </TabsContent>
<TabsContent <TabsContent value="devices" className="mt-4 space-y-2">
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"
>
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}> <div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
@@ -343,7 +343,14 @@ export const UserBehaviorDashboard = () => {
/> />
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip />} /> <Tooltip
content={
<DashboardChartTooltip
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
itemRenderer={deviceTooltipRenderer}
/>
}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -210,6 +210,8 @@ export const ChartSkeleton: React.FC<ChartSkeletonProps> = ({
// TABLE SKELETON // TABLE SKELETON
// ============================================================================= // =============================================================================
export type TableSkeletonVariant = "simple" | "detailed";
export interface TableSkeletonProps { export interface TableSkeletonProps {
/** Number of rows to show */ /** Number of rows to show */
rows?: number; rows?: number;
@@ -227,6 +229,14 @@ export interface TableSkeletonProps {
scrollable?: boolean; scrollable?: boolean;
/** Max height for scrollable (uses SCROLL_STYLES keys) */ /** Max height for scrollable (uses SCROLL_STYLES keys) */
maxHeight?: "sm" | "md" | "lg" | "xl"; 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> = ({ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
@@ -238,6 +248,8 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
className, className,
scrollable = false, scrollable = false,
maxHeight = "md", maxHeight = "md",
variant = "simple",
hasIcon = true,
}) => { }) => {
const columnCount = Array.isArray(columns) ? columns.length : columns; const columnCount = Array.isArray(columns) ? columns.length : columns;
const columnWidths = Array.isArray(columns) const columnWidths = Array.isArray(columns)
@@ -245,13 +257,50 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
: Array(columnCount).fill("w-24"); : Array(columnCount).fill("w-24");
const colors = COLOR_VARIANT_CLASSES[colorVariant]; 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 = ( const tableContent = (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, i) => ( {Array.from({ length: columnCount }).map((_, i) => (
<TableHead key={i}> <TableHead key={i} className={i === 0 ? "text-left" : "text-center"}>
<Skeleton className={cn("h-4", colors.skeleton, columnWidths[i])} /> <Skeleton
className={cn(
variant === "detailed" ? "h-8" : "h-4",
colors.skeleton,
i === 0 ? "w-24" : "w-20",
i !== 0 && "mx-auto"
)}
/>
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@@ -260,14 +309,12 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
{Array.from({ length: rows }).map((_, rowIndex) => ( {Array.from({ length: rows }).map((_, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors"> <TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors">
{Array.from({ length: columnCount }).map((_, colIndex) => ( {Array.from({ length: columnCount }).map((_, colIndex) => (
<TableCell key={colIndex}> <TableCell key={colIndex} className={colIndex !== 0 ? "text-center" : ""}>
<Skeleton {variant === "detailed" ? (
className={cn( colIndex === 0 ? renderDetailedFirstCell() : renderDetailedMetricCell()
"h-4", ) : (
colors.skeleton, renderSimpleCell(colIndex)
colIndex === 0 ? "w-32" : columnWidths[colIndex]
)} )}
/>
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>

View File

@@ -55,6 +55,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -86,6 +87,17 @@ export interface TableColumn<T = Record<string, unknown>> {
className?: string; className?: string;
/** Whether to use tabular-nums for numeric values */ /** Whether to use tabular-nums for numeric values */
numeric?: boolean; 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>> { export interface DashboardTableProps<T = Record<string, unknown>> {
@@ -119,6 +131,10 @@ export interface DashboardTableProps<T = Record<string, unknown>> {
className?: string; className?: string;
/** Additional className for the table element */ /** Additional className for the table element */
tableClassName?: string; 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, bordered = false,
className, className,
tableClassName, tableClassName,
sortConfig,
onSort,
}: DashboardTableProps<T>): React.ReactElement { }: DashboardTableProps<T>): React.ReactElement {
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3"; const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : ""; 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 // Loading skeleton
if (loading) { if (loading) {
return ( return (
@@ -241,13 +297,14 @@ export function DashboardTable<T extends Record<string, unknown>>({
key={col.key} key={col.key}
className={cn( className={cn(
TABLE_STYLES.headerCell, 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"], ALIGNMENT_CLASSES[col.align || "left"],
col.width, col.width,
col.hideOnMobile && "hidden sm:table-cell" col.hideOnMobile && "hidden sm:table-cell"
)} )}
> >
{col.header} {renderHeaderContent(col)}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>

View File

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

File diff suppressed because one or more lines are too long