/** * DashboardPage — same statistics surface as the Shopify embed Analytics page, * scoped to a tenant the CP user owns. KPI hero (today/week/month-adaptive), * range picker, 4 full bar-chart sections (commands / visits / suggested * products / AI revenue), error breakdown. * * @layer Presentation */ import { useEffect, useMemo, useState } from 'react'; import { useSearch, useNavigate } from '@tanstack/react-router'; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'; import { PageLayout } from '../../components/shared'; import { useTranslation } from '@/i18n/TranslationProvider'; import { useTenants } from '@/features/tenants/hooks/useTenants'; import { useTodayKpis, useAnalyticsOverview, useAnalyticsErrors, useAnalyticsTimeseries, useKpiTimeseries, } from '@/features/intelligence/hooks/useIntelligence'; import { resolveRange, kpiWindow, type TimeRange, type KpiWindowScope } from '@/features/intelligence/components/time-range'; import { TimeRangePicker } from '@/features/intelligence/components/TimeRangePicker'; import { KpiCard, IconVisits, IconCommands, IconProducts, IconRevenue } from '@/features/intelligence/components/KpiCard'; import { KpiBarChart } from '@/features/intelligence/components/KpiBarChart'; const USD_FMT = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); const formatCents = (c: number) => USD_FMT.format(Math.round(c / 100)); const formatPercent = (n: number) => `${(n * 100).toFixed(1)}%`; const formatMs = (n: number) => `${Math.round(n)} ms`; const ERROR_LABELS_EN: Record = { internal: 'Internal server error — the request crashed before completing.', ai_service: 'AI service unavailable — voice transcription or NLU call failed.', compilation: "Could not build an action plan from the customer's request.", not_found: 'Target element could not be found on the page (DOM mismatch).', }; interface DashSearch { range?: TimeRange; tenant?: string } export function DashboardPage() { const { t, locale } = useTranslation(); const lang: 'en' | 'de' = (locale as string)?.startsWith('de') ? 'de' : 'en'; const navigate = useNavigate(); const search = useSearch({ strict: false }) as DashSearch; const { data: tenantsRes } = useTenants(); const tenants = useMemo(() => (tenantsRes as any)?.tenants ?? (tenantsRes as any)?.items ?? (Array.isArray(tenantsRes) ? tenantsRes : []), [tenantsRes]); const fallbackTenantId: string | null = tenants?.[0]?.id ?? null; const tenantId = search.tenant && tenants.find((tn: any) => tn.id === search.tenant) ? search.tenant : fallbackTenantId; // Stable ISO strings — recomputed only when the URL range param changes. // Hooks accept ISO strings (not Date objects) so the queryKey stays // referentially stable and React Query doesn't enter a refetch loop. const rangeParam = (search.range ?? 'daily') as TimeRange; const [stable, setStable] = useState(() => { const r = resolveRange(rangeParam); const k = kpiWindow(rangeParam); return { range: r.range, granularity: r.granularity, scope: k.scope, rangeStartISO: r.startDate.toISOString(), rangeEndISO: r.endDate.toISOString(), kpiStartISO: k.startDate.toISOString(), kpiEndISO: k.endDate.toISOString() }; }); useEffect(() => { const r = resolveRange(rangeParam); const k = kpiWindow(rangeParam); setStable({ range: r.range, granularity: r.granularity, scope: k.scope, rangeStartISO: r.startDate.toISOString(), rangeEndISO: r.endDate.toISOString(), kpiStartISO: k.startDate.toISOString(), kpiEndISO: k.endDate.toISOString() }); }, [rangeParam]); const { range, granularity, scope, rangeStartISO, rangeEndISO, kpiStartISO, kpiEndISO } = stable; const setRange = (next: TimeRange) => navigate({ search: (s: any) => ({ ...s, range: next }) }); const setTenant = (id: string) => navigate({ search: (s: any) => ({ ...s, tenant: id }) }); const { data: today } = useTodayKpis(tenantId, { startISO: kpiStartISO, endISO: kpiEndISO }); const { data: overview } = useAnalyticsOverview(tenantId, { startISO: rangeStartISO, endISO: rangeEndISO }); const { data: errors } = useAnalyticsErrors(tenantId, { startISO: rangeStartISO, endISO: rangeEndISO }); const { data: timeseries } = useAnalyticsTimeseries(tenantId, granularity, { startISO: rangeStartISO, endISO: rangeEndISO }); const { data: kpiSeries } = useKpiTimeseries(tenantId, granularity, { startISO: rangeStartISO, endISO: rangeEndISO }); const heroHeading = scope === 'week' ? 'This week at a glance' : scope === 'month' ? 'This month at a glance' : 'Today at a glance'; const cmdsBarData = (timeseries ?? []).map((p) => ({ period: new Date(p.period).toLocaleDateString(locale, granularity === 'hour' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { month: 'short', day: 'numeric' }), total: p.total, })); if (!tenantId) { return
No tenants yet.
; } return ( {tenants.length > 1 && (
)} {today && (

{heroHeading}

)}

Commands over time

{!timeseries || timeseries.length === 0 ? (

No commands in this period yet.

) : (
)}

Visits over time

{kpiSeries && kpiSeries.some((p) => p.visits > 0) ? ( ) :

No visits recorded in this period yet.

}

Products suggested over time

{kpiSeries && kpiSeries.some((p) => p.suggestedProducts > 0) ? ( ) :

No products suggested in this period yet.

}

AI revenue over time

{kpiSeries && kpiSeries.some((p) => p.revenueCents > 0) ? ( ) :

No AI-attributed revenue in this period yet.

}

Error breakdown

{!errors || errors.length === 0 ?

No errors in this period.

: ( {errors.map((e) => ( ))}
What went wrongOccurrencesShare
{ERROR_LABELS_EN[e.errorType] ?? e.errorType} {e.count.toLocaleString()} {formatPercent(e.percentage)}
)}
{overview && (
Success rate {formatPercent(overview.successRate)} · Avg processing {formatMs(overview.avgProcessingMs)}
)}
); }