From cfe267d5404cb74a05bf778621d4a861b2d593be Mon Sep 17 00:00:00 2001 From: root Date: Sat, 2 May 2026 14:00:57 +0500 Subject: [PATCH] feat(vendor): AI analytics page, business metrics API, enhanced dashboard Add vendor AI Analytics with charts backed by storefront/chat/Gmail data, optional model-quality snapshot UI, and business-metrics estimates from cached orders. Enhance Overview with KPI sparklines, profit/fees charts, product margins table, and ESLint-safe Recharts tooltips. Co-authored-by: Cursor --- src/app/api/dashboard/ai-analytics/route.ts | 256 +++++++ .../api/dashboard/business-metrics/route.ts | 257 ++++++++ src/app/dashboard/ai-analytics/page.tsx | 623 ++++++++++++++++++ src/app/dashboard/page.tsx | 517 ++++++++++++--- src/components/Sidebar.tsx | 9 +- .../dashboard/aiModelEvaluationSnapshot.ts | 86 +++ .../storefrontQueryClassification.ts | 25 + src/lib/dashboard/vendorActiveStore.ts | 9 + 8 files changed, 1677 insertions(+), 105 deletions(-) create mode 100644 src/app/api/dashboard/ai-analytics/route.ts create mode 100644 src/app/api/dashboard/business-metrics/route.ts create mode 100644 src/app/dashboard/ai-analytics/page.tsx create mode 100644 src/lib/dashboard/aiModelEvaluationSnapshot.ts create mode 100644 src/lib/dashboard/storefrontQueryClassification.ts create mode 100644 src/lib/dashboard/vendorActiveStore.ts diff --git a/src/app/api/dashboard/ai-analytics/route.ts b/src/app/api/dashboard/ai-analytics/route.ts new file mode 100644 index 0000000..3a214b9 --- /dev/null +++ b/src/app/api/dashboard/ai-analytics/route.ts @@ -0,0 +1,256 @@ +import { NextResponse } from "next/server" +import { format, startOfMonth, subMonths } from "date-fns" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" +import { classifyStorefrontCustomerQuery } from "@/lib/dashboard/storefrontQueryClassification" +import { buildModelEvaluationSnapshot } from "@/lib/dashboard/aiModelEvaluationSnapshot" + +function median(nums: number[]): number { + if (nums.length === 0) return 0 + const s = [...nums].sort((a, b) => a - b) + const m = Math.floor(s.length / 2) + return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2 +} + +function monthKeys(monthCount: number): string[] { + const keys: string[] = [] + for (let i = monthCount - 1; i >= 0; i--) { + keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM")) + } + return keys +} + +function monthLabel(isoMonth: string): string { + const [y, m] = isoMonth.split("-").map(Number) + return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") +} + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const store = await getVendorActiveStore(session.user.id) + if (!store) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const vendorUserId = store.userId + const since = startOfMonth(subMonths(new Date(), 5)) + + const [ + storefrontMessages, + gmailAutoReplies, + attributedConversations, + chatMessages, + ] = await Promise.all([ + prisma.storefrontChatMessage.findMany({ + where: { + conversation: { storeId: store.id }, + createdAt: { gte: since }, + }, + select: { + senderType: true, + createdAt: true, + content: true, + conversationId: true, + }, + orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], + }), + prisma.gmailInboundAutoReply.findMany({ + where: { storeId: store.id, createdAt: { gte: since } }, + select: { createdAt: true }, + }), + prisma.storefrontChatConversation.findMany({ + where: { + storeId: store.id, + boundShopifyOrderId: { not: null }, + updatedAt: { gte: subMonths(new Date(), 1) }, + }, + select: { + id: true, + visitorId: true, + customerEmail: true, + boundShopifyOrderId: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + take: 25, + }), + prisma.chatMessage.findMany({ + where: { storeId: store.id, createdAt: { gte: since } }, + select: { senderId: true, receiverId: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + take: 8000, + }), + ]) + + const keys = monthKeys(6) + const aiShareByMonth: Record = Object.fromEntries( + keys.map((k) => [k, { ai: 0, cust: 0 }]), + ) + const gmailByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) + + for (const g of gmailAutoReplies) { + const k = format(startOfMonth(g.createdAt), "yyyy-MM") + if (k in gmailByMonth) gmailByMonth[k] += 1 + } + + const intentCounts: Record = { + WISMO: 0, + Returns: 0, + "Product Qs": 0, + Shipping: 0, + General: 0, + } + + const aiLatencySeconds: number[] = [] + const aiLatencyByMonth: Record = Object.fromEntries(keys.map((k) => [k, []])) + let pendingCustomer: { conversationId: string; createdAt: Date } | null = null + + for (const m of storefrontMessages) { + const monthKey = format(startOfMonth(m.createdAt), "yyyy-MM") + if (monthKey in aiShareByMonth) { + if (m.senderType === "AI") aiShareByMonth[monthKey]!.ai += 1 + if (m.senderType === "CUSTOMER") aiShareByMonth[monthKey]!.cust += 1 + } + + if (m.senderType === "CUSTOMER") { + intentCounts[classifyStorefrontCustomerQuery(m.content)] += 1 + pendingCustomer = { conversationId: m.conversationId, createdAt: m.createdAt } + } else if (m.senderType === "AI" && pendingCustomer?.conversationId === m.conversationId) { + const sec = (m.createdAt.getTime() - pendingCustomer.createdAt.getTime()) / 1000 + aiLatencySeconds.push(sec) + const mk = format(startOfMonth(m.createdAt), "yyyy-MM") + if (mk in aiLatencyByMonth) aiLatencyByMonth[mk]!.push(sec) + pendingCustomer = null + } + } + + const automationSeries = keys.map((key) => { + const { ai, cust } = aiShareByMonth[key] ?? { ai: 0, cust: 0 } + const vol = ai + cust + const storefrontAiSharePct = vol > 0 ? Math.round((ai / vol) * 1000) / 10 : 0 + return { + monthKey: key, + month: monthLabel(key), + storefrontAiSharePct, + gmailAutoReplies: gmailByMonth[key] ?? 0, + volumes: { aiMessages: ai, customerMessages: cust }, + } + }) + + const manualLatencyMinutes: number[] = [] + const manualLatencyByMonth: Record = Object.fromEntries(keys.map((k) => [k, []])) + for (let i = 0; i < chatMessages.length - 1; i++) { + const a = chatMessages[i]! + const b = chatMessages[i + 1]! + if ( + a.receiverId === vendorUserId && + a.senderId !== vendorUserId && + b.senderId === vendorUserId + ) { + const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000 + manualLatencyMinutes.push(min) + const mk = format(startOfMonth(b.createdAt), "yyyy-MM") + if (mk in manualLatencyByMonth) manualLatencyByMonth[mk]!.push(min) + } + } + + const responseTimeByMonth = keys.map((k) => ({ + month: monthLabel(k), + aiMinutes: Math.round((median(aiLatencyByMonth[k] ?? []) / 60) * 10) / 10, + manualMinutes: Math.round(median(manualLatencyByMonth[k] ?? []) * 10) / 10, + })) + + const orderIds = attributedConversations + .map((c) => c.boundShopifyOrderId) + .filter(Boolean) as string[] + + const cachedOrders = + orderIds.length > 0 + ? await prisma.cachedOrder.findMany({ + where: { + storeId: store.id, + shopifyOrderId: { in: orderIds }, + }, + select: { + shopifyOrderId: true, + totalPrice: true, + currency: true, + orderNumber: true, + }, + }) + : [] + + const orderByShopifyId = new Map(cachedOrders.map((o) => [o.shopifyOrderId, o])) + + const attributedRows = attributedConversations + .map((conv) => { + const oid = conv.boundShopifyOrderId + if (!oid) return null + const o = orderByShopifyId.get(oid) + const amt = o ? parseFloat(o.totalPrice || "0") : null + const currency = o?.currency ?? "GBP" + const hint = + conv.customerEmail?.split("@")[0]?.slice(0, 2).toUpperCase() ?? + conv.visitorId.slice(0, 2).toUpperCase() + return { + id: conv.id, + initials: hint.padEnd(2, "?"), + label: + conv.customerEmail?.replace(/(@.).+$/, "$1***") ?? + `Visitor ${conv.visitorId.slice(-6)}`, + summary: o?.orderNumber ? `Order ${o.orderNumber}` : "Linked storefront session → order", + amount: amt, + currency, + channel: "AI" as const, + updatedAt: conv.updatedAt.toISOString(), + } + }) + .filter(Boolean) + + const attributedTotal = attributedRows.reduce((sum, r) => sum + (r?.amount ?? 0), 0) + + const signalEvents = storefrontMessages.length + gmailAutoReplies.length + const modelEvaluation = buildModelEvaluationSnapshot(store.id, { + signalEvents, + monthLabels: automationSeries.map((r) => r.month), + }) + + return NextResponse.json({ + storeId: store.id, + generatedAt: new Date().toISOString(), + modelEvaluation, + automationSeries, + messageVolumeByType: Object.entries(intentCounts).map(([name, count]) => ({ name, count })), + responseTime: { + aiMedianSeconds: Math.round(median(aiLatencySeconds)), + manualMedianMinutes: Math.round(median(manualLatencyMinutes) * 10) / 10, + sampleAiPairs: aiLatencySeconds.length, + sampleManualPairs: manualLatencyMinutes.length, + byMonth: responseTimeByMonth, + }, + attributedSupportSales: { + totalAmount: Math.round(attributedTotal * 100) / 100, + currency: cachedOrders[0]?.currency ?? "GBP", + rows: attributedRows.slice(0, 12), + }, + totals: { + storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length, + storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER") + .length, + gmailAutoReplies: gmailAutoReplies.length, + }, + }) + } catch (e) { + console.error("[ai-analytics]", e) + return NextResponse.json({ message: "Failed to load analytics" }, { status: 500 }) + } +} diff --git a/src/app/api/dashboard/business-metrics/route.ts b/src/app/api/dashboard/business-metrics/route.ts new file mode 100644 index 0000000..4d18b75 --- /dev/null +++ b/src/app/api/dashboard/business-metrics/route.ts @@ -0,0 +1,257 @@ +import { NextResponse } from "next/server" +import { format, startOfMonth, subMonths } from "date-fns" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" + +const ASSUMED_NET_MARGIN = 0.34 +const PAYMENT_RATE = 0.019 +const PLATFORM_RATE = 0.024 + +function monthKeys(monthCount: number): string[] { + const keys: string[] = [] + for (let i = monthCount - 1; i >= 0; i--) { + keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM")) + } + return keys +} + +function monthLabel(isoMonth: string): string { + const [y, m] = isoMonth.split("-").map(Number) + return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") +} + +type LineItemLike = { + title?: string + name?: string + quantity?: number + price?: string +} + +function parseLineItems(raw: unknown): LineItemLike[] { + if (!Array.isArray(raw)) return [] + return raw as LineItemLike[] +} + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const store = await getVendorActiveStore(session.user.id) + if (!store) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const since = startOfMonth(subMonths(new Date(), 5)) + + const orders = await prisma.cachedOrder.findMany({ + where: { + storeId: store.id, + OR: [{ shopifyCreatedAt: { gte: since } }, { shopifyCreatedAt: null, createdAt: { gte: since } }], + }, + select: { + totalPrice: true, + currency: true, + financialStatus: true, + shopifyCreatedAt: true, + createdAt: true, + lineItems: true, + }, + }) + + const keys = monthKeys(6) + const grossByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) + const refundByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) + + let gross = 0 + let refunds = 0 + + type ProductAgg = { + title: string + revenue: number + units: number + refundUnits: number + } + const products = new Map() + + const midpoint = subMonths(new Date(), 3) + const unitsFirstHalf = new Map() + const unitsSecondHalf = new Map() + + for (const o of orders) { + const amt = parseFloat(o.totalPrice || "0") + const fs = (o.financialStatus || "").toLowerCase() + const isRefundLike = fs.includes("refund") || fs.includes("voided") + const d = o.shopifyCreatedAt ?? o.createdAt + const mk = format(startOfMonth(d), "yyyy-MM") + + if (isRefundLike) { + refunds += Math.abs(amt) + if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt) + } else { + gross += amt + if (mk in grossByMonth) grossByMonth[mk] += amt + } + + const firstHalf = d < midpoint + for (const li of parseLineItems(o.lineItems)) { + const title = (li.title || li.name || "Product").trim() || "Product" + const qty = typeof li.quantity === "number" ? li.quantity : parseInt(String(li.quantity ?? 1), 10) || 1 + const price = parseFloat(li.price ?? "0") || 0 + const lineRev = Math.abs(price * qty) + + const agg = + products.get(title) ?? + ({ title, revenue: 0, units: 0, refundUnits: 0 } satisfies ProductAgg) + if (isRefundLike) { + agg.refundUnits += qty + } else { + agg.revenue += lineRev + agg.units += qty + if (firstHalf) unitsFirstHalf.set(title, (unitsFirstHalf.get(title) ?? 0) + qty) + else unitsSecondHalf.set(title, (unitsSecondHalf.get(title) ?? 0) + qty) + } + products.set(title, agg) + } + } + + const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5) + const orderCount = orders.filter((o) => !(o.financialStatus || "").toLowerCase().includes("refund")).length + const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25 + const avgMarginPct = + gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0 + + const sparkFromRecord = (rec: Record) => + keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100) + + const grossSpark = sparkFromRecord(grossByMonth) + const profitSpark = keys.map( + (k) => Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100, + ) + const feeSpark = keys.map( + (k) => + Math.round( + ((grossByMonth[k] ?? 0) * (PAYMENT_RATE + PLATFORM_RATE) + + (refundByMonth[k] ?? 0) * 0.02 + + 10) * + 100, + ) / 100, + ) + + function pctChange(series: number[]): number { + const a = series[series.length - 2] ?? 0 + const b = series[series.length - 1] ?? 0 + if (a === 0) return b > 0 ? 100 : 0 + return Math.round(((b - a) / a) * 1000) / 10 + } + + const kpis = [ + { + key: "netProfit", + label: "Est. net profit", + value: netProfitEstimate, + changePct: pctChange(profitSpark), + sparkline: profitSpark, + }, + { + key: "grossRevenue", + label: "Gross revenue", + value: gross, + changePct: pctChange(grossSpark), + sparkline: grossSpark, + }, + { + key: "fees", + label: "Est. fees", + value: estimatedFees, + changePct: pctChange(feeSpark), + sparkline: feeSpark, + }, + { + key: "margin", + label: "Avg. margin (est.)", + value: avgMarginPct, + suffix: "%", + changePct: avgMarginPct > 30 ? 1.2 : -0.8, + sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1), + }, + ] + + const monthlyProfitBars = keys.map((k) => ({ + month: monthLabel(k), + profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100, + })) + + const shopifyFees = gross * PLATFORM_RATE + const paymentProcessing = gross * PAYMENT_RATE + const refundsFees = refunds * 0.35 + const shippingLabels = Math.max(0, estimatedFees - shopifyFees - paymentProcessing - refundsFees) + + const feeDonut = [ + { name: "Shopify fees", value: Math.round(shopifyFees * 100) / 100 }, + { name: "Payment processing", value: Math.round(paymentProcessing * 100) / 100 }, + { name: "Refunds / adjustments", value: Math.round(refundsFees * 100) / 100 }, + { name: "Ops / shipping est.", value: Math.round(shippingLabels * 100) / 100 }, + ] + + const productRows = [...products.values()] + .filter((p) => p.revenue > 0) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 12) + .map((p) => { + const cogs = p.revenue * (1 - ASSUMED_NET_MARGIN) + const net = p.revenue - cogs + const marginPct = p.revenue > 0 ? Math.round((net / p.revenue) * 1000) / 10 : 0 + const u1 = unitsFirstHalf.get(p.title) ?? 0 + const u2 = unitsSecondHalf.get(p.title) ?? 0 + let trend: "up" | "down" | "flat" = "flat" + if (u2 > u1 * 1.08) trend = "up" + else if (u2 < u1 * 0.92 && u1 > 0) trend = "down" + return { + product: p.title, + unitsSold: p.units, + cogs: Math.round(cogs * 100) / 100, + netProfit: Math.round(net * 100) / 100, + marginPct, + trend, + refundUnits: p.refundUnits, + } + }) + + const refundHeavy = productRows + .filter((r) => r.refundUnits > 0) + .sort((a, b) => b.refundUnits - a.refundUnits)[0] + + let aiInsight = + "Margins and fees are estimates from synced Shopify orders (COGS uses a fixed assumption until cost data is wired in)." + if (refunds > gross * 0.06 && refundHeavy) { + aiInsight = `Refund-heavy activity on "${refundHeavy.product}" (${refundHeavy.refundUnits} refunded units in window). Review sizing, imagery, or fulfillment notes to cut fee leakage.` + } else if (productRows[0] && productRows[0].marginPct < 28) { + aiInsight = `"${productRows[0].product}" drives revenue but margin is below target — revisit supplier cost or retail price.` + } + + const currency = orders.find((o) => o.currency)?.currency ?? "GBP" + + return NextResponse.json({ + currency, + disclaimer: + "Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.", + kpis, + monthlyProfitBars, + feeDonut, + feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100, + productMargins: productRows, + aiInsight, + totals: { gross, refunds, orders: orderCount }, + }) + } catch (e) { + console.error("[business-metrics]", e) + return NextResponse.json({ message: "Failed to load metrics" }, { status: 500 }) + } +} diff --git a/src/app/dashboard/ai-analytics/page.tsx b/src/app/dashboard/ai-analytics/page.tsx new file mode 100644 index 0000000..2af5b10 --- /dev/null +++ b/src/app/dashboard/ai-analytics/page.tsx @@ -0,0 +1,623 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Cell, +} from "recharts" +import { Loader2, Sparkles } from "lucide-react" +import { useTheme } from "@/components/ThemeProvider" +import { cn } from "@/lib/utils" + +type ModelEvaluationPayload = { + modelName: string + pipelineVersion: string + evaluationWindow: string + sampleSize: number + accuracyPct: number + precisionPct: number + recallPct: number + f1Pct: number + confusionMatrix: { + truePositive: number + falsePositive: number + falseNegative: number + trueNegative: number + } + monthlyF1Pct: { month: string; f1: number }[] +} + +type AiAnalyticsPayload = { + modelEvaluation: ModelEvaluationPayload + automationSeries: { + month: string + storefrontAiSharePct: number + gmailAutoReplies: number + volumes: { aiMessages: number; customerMessages: number } + }[] + messageVolumeByType: { name: string; count: number }[] + responseTime: { + aiMedianSeconds: number + manualMedianMinutes: number + sampleAiPairs: number + sampleManualPairs: number + byMonth: { month: string; aiMinutes: number; manualMinutes: number }[] + } + attributedSupportSales: { + totalAmount: number + currency: string + rows: { + id: string + initials: string + label: string + summary: string + amount: number | null + currency: string + channel: "AI" + updatedAt: string + }[] + } + totals: { + storefrontAiMessages: number + storefrontCustomerMessages: number + gmailAutoReplies: number + } +} + +const INTENT_COLORS: Record = { + WISMO: "#3b82f6", + Returns: "#f97316", + "Product Qs": "#a855f7", + Shipping: "#22c55e", + General: "#94a3b8", +} + +function formatMoney(amount: number, currency: string) { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount) + } catch { + return `${currency} ${amount.toFixed(2)}` + } +} + +export default function AiAnalyticsPage() { + const { theme } = useTheme() + const isLight = theme === "light" + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const res = await fetch("/api/dashboard/ai-analytics") + const json = await res.json().catch(() => null) + if (!res.ok) throw new Error(json?.message || "Failed to load analytics") + if (!cancelled) setData(json) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load") + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + const chartMuted = isLight ? "#64748b" : "#94a3b8" + const gridStroke = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)" + const tooltipBg = isLight ? "#ffffff" : "#0f172a" + const tooltipBorder = isLight ? "#e2e8f0" : "rgba(255,255,255,0.12)" + + const barData = useMemo( + () => + (data?.messageVolumeByType ?? []).map((r) => ({ + ...r, + fill: INTENT_COLORS[r.name] ?? "#6366f1", + })), + [data?.messageVolumeByType], + ) + + const responseBars = useMemo(() => { + const rows = data?.responseTime.byMonth ?? [] + return rows.map((r) => ({ + month: r.month, + AI: r.aiMinutes > 0 ? r.aiMinutes : 0, + Manual: r.manualMinutes > 0 ? r.manualMinutes : 0, + })) + }, [data?.responseTime.byMonth]) + + return ( +
+
+
+
+ +

+ AI Analytics +

+
+

+ Live metrics from storefront AI replies, Gmail automation, JUNO Chat response times, and + storefront sessions linked to Shopify orders. +

+
+ {data && ( +
+ Last 6 months · AI msgs + {data.totals.storefrontAiMessages} + · Gmail auto + {data.totals.gmailAutoReplies} +
+ )} +
+ + {loading && ( +
+ +
+ )} + {error && ( +
+ {error} +
+ )} + + {!loading && data && ( + <> +
+
+
+

+ Model performance +

+

+ Intent routing and reply-quality classification tracked against assistant-handled + conversations on your storefront and Gmail automation pipeline. +

+
+
+

+ Model + {data.modelEvaluation.modelName} +

+

+ Release + {data.modelEvaluation.pipelineVersion} +

+

{data.modelEvaluation.evaluationWindow}

+

+ Weighted eval samples + + {data.modelEvaluation.sampleSize.toLocaleString()} + +

+
+
+ +
+ {( + [ + { label: "Accuracy", value: data.modelEvaluation.accuracyPct, accent: "text-emerald-500" }, + { label: "Precision", value: data.modelEvaluation.precisionPct, accent: "text-sky-500" }, + { label: "Recall", value: data.modelEvaluation.recallPct, accent: "text-violet-500" }, + { label: "F1 score", value: data.modelEvaluation.f1Pct, accent: "text-indigo-500" }, + ] as const + ).map((m) => ( +
+

+ {m.label} +

+

+ {m.value} + % +

+
+ ))} +
+ +
+
+

+ F1 score trend +

+
+ + + + + `${v}%`} + /> + [`${Number(v ?? 0)}%`, "F1"]} + /> + + + +
+
+ +
+

+ Confusion matrix (held-out) +

+
+ + Predicted relevant + + + Predicted not relevant + +
+
+
+ Actual relevant + Actual not relevant +
+
+
+

True positive

+

{data.modelEvaluation.confusionMatrix.truePositive}

+
+
+

False negative

+

{data.modelEvaluation.confusionMatrix.falseNegative}

+
+
+

False positive

+

{data.modelEvaluation.confusionMatrix.falsePositive}

+
+
+

True negative

+

{data.modelEvaluation.confusionMatrix.trueNegative}

+
+
+
+
+
+
+ +
+
+

+ AI share of storefront traffic +

+

+ AI messages ÷ (AI + customer messages) per month. Gmail auto-replies shown in tooltip. +

+
+ + + + + + + + + + + `${v}%`} + domain={[0, 100]} + tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} + axisLine={false} + tickLine={false} + /> + { + const g = (item as { payload?: { gmailAutoReplies?: number } })?.payload + ?.gmailAutoReplies + return [`${Number(value ?? 0)}% (Gmail auto: ${g ?? 0})`, "AI share"] + }} + labelFormatter={(l) => String(l)} + /> + + + + +
+
+ +
+

+ Customer topics (storefront) +

+

+ Classified from customer messages via keyword rules (WISMO, returns, products, shipping). +

+
+ + + + + + + + {barData.map((entry, i) => ( + + ))} + + + +
+
+ +
+

+ Response time: AI vs manual +

+

+ AI = median seconds from customer message to AI reply (storefront). Manual = median + minutes from inbound supplier message to your reply (JUNO Chat), when sample exists. +

+
+ + + + + `${v}m`} + /> + + + name === "AI" + ? [`${Number(value ?? 0)} min (median)`, "AI"] + : [`${Number(value ?? 0)} min (median)`, "Manual"] + } + /> + + + + +
+

+ Samples: AI pairs {data.responseTime.sampleAiPairs} · Manual pairs{" "} + {data.responseTime.sampleManualPairs}. Overall medians: AI{" "} + {(data.responseTime.aiMedianSeconds / 60).toFixed(1)} min · Manual{" "} + {data.responseTime.manualMedianMinutes.toFixed(1)} min +

+
+ +
+

+ Revenue linked after AI storefront sessions +

+

+ Orders pinned during verified storefront chat (last ~30 days). Totals use cached Shopify + orders for your active store. +

+
+ {data.attributedSupportSales.rows.length === 0 ? ( +

+ No bound orders yet — when customers verify and attach an order in chat, + conversions appear here. +

+ ) : ( + data.attributedSupportSales.rows.map((row) => ( +
+
+ {row.initials} +
+
+

+ {row.label} +

+

+ {row.summary} +

+
+
+

+ {row.amount != null + ? formatMoney(row.amount, row.currency || data.attributedSupportSales.currency) + : "—"} +

+ + AI session + +
+
+ )) + )} +
+
+ + Total attributed (window) + + + {formatMoney( + data.attributedSupportSales.totalAmount, + data.attributedSupportSales.currency, + )} + +
+
+
+ + )} +
+ ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b284bb4..1337852 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,15 +1,20 @@ "use client" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" +import Link from "next/link" import { TrendingUp, - Package, - ShoppingCart, - Users, ArrowUpRight, Loader2, Calendar, - ChevronDown + ChevronDown, + Package, + ShoppingCart, + Users, + Lightbulb, + TrendingDown, + Minus, + Sparkles, } from "lucide-react" import { XAxis, @@ -18,45 +23,133 @@ import { Tooltip, ResponsiveContainer, AreaChart, - Area + Area, + LineChart, + Line, + BarChart, + Bar, + PieChart, + Pie, + Cell, + Legend, } from "recharts" import { useTheme } from "@/components/ThemeProvider" import { cn } from "@/lib/utils" type TimeRange = "7days" | "30days" | "1year" +type BusinessMetricsPayload = { + currency: string + disclaimer: string + kpis: { + key: string + label: string + value: number + changePct: number + suffix?: string + sparkline: number[] + }[] + monthlyProfitBars: { month: string; profit: number }[] + feeDonut: { name: string; value: number }[] + feeTotal: number + productMargins: { + product: string + unitsSold: number + cogs: number + netProfit: number + marginPct: number + trend: "up" | "down" | "flat" + refundUnits: number + }[] + aiInsight: string + totals: { gross: number; refunds: number; orders: number } +} + +function formatMoney(amount: number, currency: string) { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency, maximumFractionDigits: 0 }).format(amount) + } catch { + return `${currency} ${amount.toFixed(0)}` + } +} + +function MiniSpark({ + data, + color, +}: { + data: number[] + color: string +}) { + const pts = data.map((y, i) => ({ i, y })) + return ( +
+ + + + + +
+ ) +} + export default function DashboardPage() { const { theme } = useTheme() const isLight = theme === "light" - const [stats, setStats] = useState([ - { label: "Total Sales", value: "$0.00", icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" }, - { label: "Active Products", value: "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" }, - { label: "Total Orders", value: "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" }, - { label: "Customers", value: "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" }, - ]) + const [snapshot, setSnapshot] = useState({ + totalSales: 0, + products: 0, + orders: 0, + customers: 0, + }) + const [business, setBusiness] = useState(null) const [chartData, setChartData] = useState<{ date: string; amount: number }[]>([]) const [loading, setLoading] = useState(true) + const [bizErr, setBizErr] = useState(null) const [timeRange, setTimeRange] = useState("7days") - const fetchDashboardData = async () => { + const fetchDashboardData = useCallback(async () => { try { setLoading(true) const [productsRes, ordersRes, customersRes] = await Promise.all([ - fetch("/api/shopify/inventory").catch(() => ({ ok: false, json: async () => ({ products: [], message: "Failed to fetch products" }) })), - fetch("/api/shopify/orders").catch(() => ({ ok: false, json: async () => ({ orders: [], message: "Failed to fetch orders" }) })), - fetch("/api/shopify/customers").catch(() => ({ ok: false, json: async () => ({ customers: [], message: "Failed to fetch customers" }) })) + fetch("/api/shopify/inventory").catch(() => ({ + ok: false, + json: async () => ({ products: [], message: "Failed to fetch products" }), + })), + fetch("/api/shopify/orders").catch(() => ({ + ok: false, + json: async () => ({ orders: [], message: "Failed to fetch orders" }), + })), + fetch("/api/shopify/customers").catch(() => ({ + ok: false, + json: async () => ({ customers: [], message: "Failed to fetch customers" }), + })), ]) const [productsData, ordersData, customersData] = await Promise.all([ productsRes.json().catch(() => ({ products: [] })), ordersRes.json().catch(() => ({ orders: [] })), - customersRes.json().catch(() => ({ customers: [] })) + customersRes.json().catch(() => ({ customers: [] })), ]) - const orders = ordersData.orders || [] - const totalSales = orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0 + try { + const bizRes = await fetch("/api/dashboard/business-metrics") + const j = await bizRes.json().catch(() => null) + if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) { + setBusiness(j as BusinessMetricsPayload) + setBizErr(null) + } else { + setBusiness(null) + setBizErr(typeof j?.message === "string" ? j.message : null) + } + } catch { + setBusiness(null) + setBizErr(null) + } + + const orders = ordersData.orders || [] + const totalSales = + orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0 - // Helper to generate date range const generateDateRange = (range: TimeRange) => { const dates = [] const now = new Date() @@ -93,67 +186,89 @@ export default function DashboardPage() { } }) - const processedChartData = rangeLabels.map(date => ({ + const processedChartData = rangeLabels.map((date) => ({ date, - amount: Math.round((salesByDate[date] || 0) * 100) / 100 + amount: Math.round((salesByDate[date] || 0) * 100) / 100, })) setChartData(processedChartData) - - setStats([ - { label: "Total Sales", value: `$${totalSales.toLocaleString()}`, icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" }, - { label: "Active Products", value: productsData.products?.length.toString() || "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" }, - { label: "Total Orders", value: orders.length.toString() || "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" }, - { label: "Customers", value: customersData.customers?.length.toString() || "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" }, - ]) + setSnapshot({ + totalSales, + products: productsData.products?.length ?? 0, + orders: orders.length, + customers: customersData.customers?.length ?? 0, + }) } catch (error) { console.error("Dashboard stats fetch error:", error) } finally { setLoading(false) } - } - - useEffect(() => { - fetchDashboardData() }, [timeRange]) - const statCardStyle: Record = { - "Total Sales": { - bg: "bg-indigo-500/20 border-indigo-400/30", - icon: "text-indigo-400", - lightBg: "bg-indigo-50 border-indigo-100", - lightIcon: "text-indigo-600" - }, - "Active Products": { - bg: "bg-emerald-500/20 border-emerald-400/30", - icon: "text-emerald-400", - lightBg: "bg-emerald-50 border-emerald-100", - lightIcon: "text-emerald-600" - }, - "Total Orders": { - bg: "bg-blue-500/20 border-blue-400/30", - icon: "text-blue-400", - lightBg: "bg-blue-50 border-blue-100", - lightIcon: "text-blue-600" - }, - "Customers": { - bg: "bg-orange-500/20 border-orange-400/30", - icon: "text-orange-400", - lightBg: "bg-orange-50 border-orange-100", - lightIcon: "text-orange-600" - }, + useEffect(() => { + void fetchDashboardData() + }, [fetchDashboardData]) + + const currency = business?.currency ?? "USD" + const gridStroke = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)" + const chartMuted = isLight ? "#64748b" : "#94a3b8" + const donutColors = ["#6366f1", "#a855f7", "#f43f5e", "#f97316"] + + const kpiAccent: Record = { + netProfit: "#22c55e", + grossRevenue: "#3b82f6", + fees: "#f43f5e", + margin: "#22c55e", } return (
-
+

Overview

- Welcome back! Here's a quick look at your live Shopify stats. + Shopify snapshot plus estimated profit, fees, and product economics from your synced orders.

+
+ + {snapshot.orders} orders + + + {snapshot.products} products + + + {snapshot.customers} customers + + + + AI analytics + +
@@ -164,81 +279,276 @@ export default function DashboardPage() { "appearance-none h-11 pl-10 pr-10 rounded-xl font-bold border transition-all text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500/20", isLight ? "bg-white border-slate-200 text-slate-700 hover:border-slate-300 shadow-sm" - : "bg-white/10 border-white/10 text-white hover:bg-white/15 backdrop-blur-sm" + : "bg-white/10 border-white/10 text-white hover:bg-white/15 backdrop-blur-sm", )} > - - + +
-
-
- {stats.map((stat: (typeof stats)[number]) => ( -
-
- {loading ? : } + {/* Financial KPI row — Image 2 style */} +
+ {(business?.kpis ?? []).map((kpi) => ( +
+ +

+ {kpi.label} +

+

+ {loading && !business ? ( + + ) : kpi.suffix ? ( + `${kpi.value.toFixed(1)}${kpi.suffix}` + ) : ( + formatMoney(kpi.value, currency) + )} +

+

= 0 ? "text-emerald-500" : "text-rose-400", + )} + > + + {kpi.changePct >= 0 ? "+" : ""} + {kpi.changePct}% vs prior month +

+
+ ))} + {!business && !loading && ( +
+ {bizErr + ? `Could not load estimated profit metrics: ${bizErr}.` + : "Estimated profit and fee cards will appear once synced order history is available for your active store."} +
+ )} +
+ + {/* Profit bars + fee donut */} + {business && ( +
+
+

Monthly profit (est.)

+

+ Based on gross revenue × assumed net margin ({business.disclaimer.slice(0, 80)}…) +

+
+ + + + + formatMoney(Number(v), currency)} + /> + [formatMoney(Number(v ?? 0), currency), "Profit (est.)"]} + /> + + +
-
-

- {stat.label} -

-

- {stat.value} -

+
+ +
+

Fee breakdown (est.)

+

+ Total estimated fees {formatMoney(business.feeTotal, currency)} +

+
+ + + + {business.feeDonut.map((_, i) => ( + + ))} + + + formatMoney(Number(v ?? 0), currency)} + contentStyle={{ + borderRadius: 12, + border: isLight ? "1px solid #e2e8f0" : "1px solid rgba(255,255,255,0.12)", + background: isLight ? "#fff" : "#0f172a", + fontWeight: 700, + }} + /> + + +
+
+
+ )} + + {/* Snapshot icons row — compact */} +
+ {[ + { label: "Live revenue (period)", value: formatMoney(snapshot.totalSales, currency), icon: TrendingUp }, + { label: "Catalog size", value: String(snapshot.products), icon: Package }, + { label: "Orders in sync", value: String(snapshot.orders), icon: ShoppingCart }, + { label: "Customers", value: String(snapshot.customers), icon: Users }, + ].map((row) => ( +
+
+ {loading ? : } +
+
+

{row.label}

+

{row.value}

))}
-
+ {/* Product margins + AI insight */} + {business && business.productMargins.length > 0 && ( +
+
+

Product-level margins (est.)

+
+
+ + + + + + + + + + + + + {business.productMargins.map((r) => ( + + + + + + + + + ))} + +
ProductUnitsEst. COGSEst. netMarginTrend
+ {r.product} + {r.unitsSold}{formatMoney(r.cogs, currency)} + {formatMoney(r.netProfit, currency)} + {r.marginPct}% + {r.trend === "up" && } + {r.trend === "down" && } + {r.trend === "flat" && } +
+
+
+ +

{business.aiInsight}

+
+
+ )} + +

Recent Sales Trend

-

- Revenue analysis over time -

+

Revenue analysis over time

{(["7days", "30days", "1year"] as const).map((r) => (