fix: filters, UI

This commit is contained in:
root 2026-05-12 21:40:23 +05:00
parent 23ab998311
commit 6765e2bbf4
6 changed files with 245 additions and 98 deletions

View File

@ -297,6 +297,29 @@ function productQuestionLabel(content: string): string {
) )
} }
function incrementProductQuestion(
rows: Map<string, { name: string; count: number }>,
name: string,
) {
const clean = name.trim()
if (!clean) return
rows.set(clean, {
name: clean,
count: (rows.get(clean)?.count ?? 0) + 1,
})
}
function cartEventTypes() {
return new Set([
"add_to_cart_clicked",
"upsell_clicked",
"cross_sell_clicked",
"cart_link_opened",
"cart_link_generated",
"cart_action_shown",
])
}
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const session = await auth() const session = await auth()
@ -456,7 +479,7 @@ export async function GET(req: Request) {
if (event.eventType === "ticket_created") row.ticketsCreated += 1 if (event.eventType === "ticket_created") row.ticketsCreated += 1
if (event.eventType === "upsell_shown") row.upsellShown += 1 if (event.eventType === "upsell_shown") row.upsellShown += 1
if (event.eventType === "upsell_clicked") row.upsellClicked += 1 if (event.eventType === "upsell_clicked") row.upsellClicked += 1
if (event.eventType === "add_to_cart_clicked" || event.eventType === "cart_link_opened") row.cartActions += 1 if (cartEventTypes().has(event.eventType)) row.cartActions += 1
if (event.sentiment === "positive") row.positiveSentiment += 1 if (event.sentiment === "positive") row.positiveSentiment += 1
if (event.sentiment === "neutral") row.neutralSentiment += 1 if (event.sentiment === "neutral") row.neutralSentiment += 1
if (event.sentiment === "negative") row.negativeSentiment += 1 if (event.sentiment === "negative") row.negativeSentiment += 1
@ -496,14 +519,18 @@ export async function GET(req: Request) {
const bucket = classifyStorefrontCustomerQuery(message.content) const bucket = classifyStorefrontCustomerQuery(message.content)
intentCounts[bucket] += 1 intentCounts[bucket] += 1
if (bucket === "Product Qs") { if (bucket === "Product Qs") {
const name = productQuestionLabel(message.content) incrementProductQuestion(productQuestionCounts, productQuestionLabel(message.content))
productQuestionCounts.set(name, {
name,
count: (productQuestionCounts.get(name)?.count ?? 0) + 1,
})
} }
pendingByConversation.set(message.conversationId, message.createdAt) pendingByConversation.set(message.conversationId, message.createdAt)
} else if (message.senderType === "AI") { } else if (message.senderType === "AI") {
for (const action of parseStorefrontChatActions(message.actions)) {
if (action.type === "add_to_cart" || action.type === "upsell_suggestion") {
incrementProductQuestion(productQuestionCounts, action.product.title)
}
if (action.type === "open_cart" && action.product?.title) {
incrementProductQuestion(productQuestionCounts, action.product.title)
}
}
const pendingAt = pendingByConversation.get(message.conversationId) const pendingAt = pendingByConversation.get(message.conversationId)
if (!pendingAt) continue if (!pendingAt) continue
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000 const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
@ -513,12 +540,18 @@ export async function GET(req: Request) {
} }
const manualLatencyMinutes: number[] = [] const manualLatencyMinutes: number[] = []
for (let i = 0; i < chatMessages.length - 1; i++) { const pendingManualByCounterparty = new Map<string, Date>()
const a = chatMessages[i]! for (const message of chatMessages) {
const b = chatMessages[i + 1]! if (message.receiverId === vendorUserId && message.senderId !== vendorUserId) {
if (a.receiverId === vendorUserId && a.senderId !== vendorUserId && b.senderId === vendorUserId) { pendingManualByCounterparty.set(message.senderId, message.createdAt)
const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000 continue
if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min) }
if (message.senderId === vendorUserId) {
const pendingAt = pendingManualByCounterparty.get(message.receiverId)
if (!pendingAt) continue
const min = (message.createdAt.getTime() - pendingAt.getTime()) / 60_000
if (min >= 0 && min <= 8 * 60) manualLatencyMinutes.push(min)
pendingManualByCounterparty.delete(message.receiverId)
} }
} }
@ -531,12 +564,20 @@ export async function GET(req: Request) {
const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length
const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length
const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length
const cartClicks = analyticsEvents.filter((event) => const cartTypes = cartEventTypes()
["add_to_cart_clicked", "upsell_clicked", "cross_sell_clicked"].includes(event.eventType), const cartEvents = analyticsEvents.filter((event) => cartTypes.has(event.eventType))
).length const cartClicks = cartEvents.length
const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length
const conversionBase = cartClicks || cartOpenCount
const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size
const cartConversationIds = new Set(
cartEvents.map((event) => event.conversationId).filter((id): id is string => Boolean(id)),
)
const cartAttributedOrderCount = new Set(
revenueAttribution.assistedRows
.filter((row) => cartConversationIds.has(row.conversationId))
.map((row) => row.orderId),
).size
const conversionBase = cartConversationIds.size || cartClicks
const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP" const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP"
const sentimentTotals = { const sentimentTotals = {
@ -549,7 +590,11 @@ export async function GET(req: Request) {
const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0) const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
const aiMedianSeconds = median(aiLatencySeconds) const aiMedianSeconds = median(aiLatencySeconds)
const manualMedianMinutes = median(manualLatencyMinutes) const manualMedianMinutes = median(manualLatencyMinutes)
const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60) const manualBenchmarkMinutes = manualLatencyMinutes.length > 0 ? manualMedianMinutes : 3
const minutesSavedPerAnswer = Math.min(
10,
Math.max(0, manualBenchmarkMinutes - aiMedianSeconds / 60),
)
const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10 const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
const topProductQuestions = Array.from(productQuestionCounts.values()) const topProductQuestions = Array.from(productQuestionCounts.values())
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
@ -573,12 +618,12 @@ export async function GET(req: Request) {
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue), directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue), directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue), aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
aiCartConversionRate: pct(directOrderCount, conversionBase), aiCartConversionRate: pct(cartAttributedOrderCount || directOrderCount, conversionBase),
averageAiInfluencedOrderValue: averageAiInfluencedOrderValue:
revenueAttribution.assistedRows.length > 0 revenueAttribution.assistedRows.length > 0
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length) ? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
: 0, : 0,
pendingAttributionCount: Math.max(0, cartClicks - directOrderCount), pendingAttributionCount: Math.max(0, conversionBase - (cartAttributedOrderCount || directOrderCount)),
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({ directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
...row, ...row,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@ -588,7 +633,7 @@ export async function GET(req: Request) {
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
})), })),
methodology: methodology:
"Direct revenue only counts AI-clicked products later found in a matching order. Assisted revenue counts full matching orders after AI engagement.", "Direct upsell/cross-sell revenue only counts clicked AI-suggested products later found in matching orders. Cart conversion counts cart-engaged conversations that later match a cached Shopify order.",
}, },
customerExperience: { customerExperience: {
sentimentMix: { sentimentMix: {
@ -613,7 +658,7 @@ export async function GET(req: Request) {
resolvedByAi, resolvedByAi,
ticketsCreated, ticketsCreated,
aiMedianSeconds: Math.round(aiMedianSeconds), aiMedianSeconds: Math.round(aiMedianSeconds),
manualMedianMinutes: Math.round(manualMedianMinutes * 10) / 10, manualMedianMinutes: Math.round(manualBenchmarkMinutes * 10) / 10,
sampleAiPairs: aiLatencySeconds.length, sampleAiPairs: aiLatencySeconds.length,
sampleManualPairs: manualLatencyMinutes.length, sampleManualPairs: manualLatencyMinutes.length,
hoursSavedEstimate, hoursSavedEstimate,

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { format, startOfMonth, subMonths } from "date-fns" import { format, startOfDay, startOfMonth, subDays, subMonths } from "date-fns"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore"
@ -8,17 +8,51 @@ const ASSUMED_NET_MARGIN = 0.34
const PAYMENT_RATE = 0.019 const PAYMENT_RATE = 0.019
const PLATFORM_RATE = 0.024 const PLATFORM_RATE = 0.024
function monthKeys(monthCount: number): string[] { type DashboardRange = "7days" | "30days" | "1year"
const keys: string[] = []
for (let i = monthCount - 1; i >= 0; i--) { function parseRange(input: string | null): DashboardRange {
keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM")) if (input === "30days" || input === "1year" || input === "7days") return input
} return "7days"
return keys
} }
function monthLabel(isoMonth: string): string { function rangeWindow(range: DashboardRange, now = new Date()) {
const [y, m] = isoMonth.split("-").map(Number) if (range === "1year") {
const since = startOfMonth(subMonths(now, 11))
return {
since,
previousSince: startOfMonth(subMonths(now, 23)),
bucketCount: 12,
bucketKey: (date: Date) => format(startOfMonth(date), "yyyy-MM"),
bucketLabel: (key: string) => {
const [y, m] = key.split("-").map(Number)
return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") return format(new Date(y!, (m ?? 1) - 1, 1), "MMM")
},
previousLabel: "prior year",
}
}
const days = range === "30days" ? 30 : 7
const since = subDays(startOfDay(now), days - 1)
return {
since,
previousSince: subDays(since, days),
bucketCount: days,
bucketKey: (date: Date) => format(startOfDay(date), "yyyy-MM-dd"),
bucketLabel: (key: string) => format(new Date(`${key}T00:00:00`), "MMM d"),
previousLabel: range === "30days" ? "prior 30 days" : "prior 7 days",
}
}
function rangeKeys(config: ReturnType<typeof rangeWindow>): string[] {
const keys: string[] = []
for (let i = config.bucketCount - 1; i >= 0; i--) {
const d =
config.bucketCount === 12
? startOfMonth(subMonths(new Date(), i))
: subDays(startOfDay(new Date()), i)
keys.push(config.bucketKey(d))
}
return keys
} }
type LineItemLike = { type LineItemLike = {
@ -33,7 +67,7 @@ function parseLineItems(raw: unknown): LineItemLike[] {
return raw as LineItemLike[] return raw as LineItemLike[]
} }
export async function GET() { export async function GET(req: Request) {
try { try {
const session = await auth() const session = await auth()
if (!session?.user?.id) { if (!session?.user?.id) {
@ -48,12 +82,17 @@ export async function GET() {
return NextResponse.json({ message: "No active store" }, { status: 400 }) return NextResponse.json({ message: "No active store" }, { status: 400 })
} }
const since = startOfMonth(subMonths(new Date(), 5)) const { searchParams } = new URL(req.url)
const range = parseRange(searchParams.get("range"))
const config = rangeWindow(range)
const orders = await prisma.cachedOrder.findMany({ const orders = await prisma.cachedOrder.findMany({
where: { where: {
storeId: store.id, storeId: store.id,
OR: [{ shopifyCreatedAt: { gte: since } }, { shopifyCreatedAt: null, createdAt: { gte: since } }], OR: [
{ shopifyCreatedAt: { gte: config.previousSince } },
{ shopifyCreatedAt: null, createdAt: { gte: config.previousSince } },
],
}, },
select: { select: {
totalPrice: true, totalPrice: true,
@ -65,12 +104,14 @@ export async function GET() {
}, },
}) })
const keys = monthKeys(6) const keys = rangeKeys(config)
const grossByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0])) const grossByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
const refundByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0])) const refundByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
let gross = 0 let gross = 0
let refunds = 0 let refunds = 0
let previousGross = 0
let previousRefunds = 0
type ProductAgg = { type ProductAgg = {
title: string title: string
@ -89,8 +130,11 @@ export async function GET() {
const fs = (o.financialStatus || "").toLowerCase() const fs = (o.financialStatus || "").toLowerCase()
const isRefundLike = fs.includes("refund") || fs.includes("voided") const isRefundLike = fs.includes("refund") || fs.includes("voided")
const d = o.shopifyCreatedAt ?? o.createdAt const d = o.shopifyCreatedAt ?? o.createdAt
const mk = format(startOfMonth(d), "yyyy-MM") const isCurrentPeriod = d >= config.since
const isPreviousPeriod = d >= config.previousSince && d < config.since
const mk = config.bucketKey(d)
if (isCurrentPeriod) {
if (isRefundLike) { if (isRefundLike) {
refunds += Math.abs(amt) refunds += Math.abs(amt)
if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt) if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt)
@ -98,8 +142,13 @@ export async function GET() {
gross += amt gross += amt
if (mk in grossByMonth) grossByMonth[mk] += amt if (mk in grossByMonth) grossByMonth[mk] += amt
} }
} else if (isPreviousPeriod) {
if (isRefundLike) previousRefunds += Math.abs(amt)
else previousGross += amt
}
const firstHalf = d < midpoint const firstHalf = d < midpoint
if (!isCurrentPeriod) continue
for (const li of parseLineItems(o.lineItems)) { for (const li of parseLineItems(o.lineItems)) {
const title = (li.title || li.name || "Product").trim() || "Product" 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 qty = typeof li.quantity === "number" ? li.quantity : parseInt(String(li.quantity ?? 1), 10) || 1
@ -122,10 +171,19 @@ export async function GET() {
} }
const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5) const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5)
const orderCount = orders.filter((o) => !(o.financialStatus || "").toLowerCase().includes("refund")).length const orderCount = orders.filter((o) => {
const d = o.shopifyCreatedAt ?? o.createdAt
return d >= config.since && !(o.financialStatus || "").toLowerCase().includes("refund")
}).length
const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25 const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25
const avgMarginPct = const avgMarginPct =
gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0 gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0
const previousNetProfitEstimate = Math.max(0, previousGross * ASSUMED_NET_MARGIN - previousRefunds * 0.5)
const previousFees = previousGross * (PAYMENT_RATE + PLATFORM_RATE) + previousRefunds * 0.02
const previousAvgMarginPct =
previousGross > 0
? Math.round(((previousNetProfitEstimate + previousRefunds * 0.5) / previousGross) * 1000) / 10
: 0
const sparkFromRecord = (rec: Record<string, number>) => const sparkFromRecord = (rec: Record<string, number>) =>
keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100) keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100)
@ -144,9 +202,9 @@ export async function GET() {
) / 100, ) / 100,
) )
function pctChange(series: number[]): number { function pctChangeValue(current: number, previous: number): number {
const a = series[series.length - 2] ?? 0 const a = previous
const b = series[series.length - 1] ?? 0 const b = current
if (a === 0) return b > 0 ? 100 : 0 if (a === 0) return b > 0 ? 100 : 0
return Math.round(((b - a) / a) * 1000) / 10 return Math.round(((b - a) / a) * 1000) / 10
} }
@ -156,21 +214,21 @@ export async function GET() {
key: "netProfit", key: "netProfit",
label: "Est. net profit", label: "Est. net profit",
value: netProfitEstimate, value: netProfitEstimate,
changePct: pctChange(profitSpark), changePct: pctChangeValue(netProfitEstimate, previousNetProfitEstimate),
sparkline: profitSpark, sparkline: profitSpark,
}, },
{ {
key: "grossRevenue", key: "grossRevenue",
label: "Gross revenue", label: "Gross revenue",
value: gross, value: gross,
changePct: pctChange(grossSpark), changePct: pctChangeValue(gross, previousGross),
sparkline: grossSpark, sparkline: grossSpark,
}, },
{ {
key: "fees", key: "fees",
label: "Est. fees", label: "Est. fees",
value: estimatedFees, value: estimatedFees,
changePct: pctChange(feeSpark), changePct: pctChangeValue(estimatedFees, previousFees),
sparkline: feeSpark, sparkline: feeSpark,
}, },
{ {
@ -178,13 +236,13 @@ export async function GET() {
label: "Avg. margin (est.)", label: "Avg. margin (est.)",
value: avgMarginPct, value: avgMarginPct,
suffix: "%", suffix: "%",
changePct: avgMarginPct > 30 ? 1.2 : -0.8, changePct: pctChangeValue(avgMarginPct, previousAvgMarginPct),
sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1), sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1),
}, },
] ]
const monthlyProfitBars = keys.map((k) => ({ const monthlyProfitBars = keys.map((k) => ({
month: monthLabel(k), month: config.bucketLabel(k),
profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100, profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100,
})) }))
@ -240,6 +298,8 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
currency, currency,
range,
comparisonLabel: config.previousLabel,
disclaimer: disclaimer:
"Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.", "Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.",
kpis, kpis,
@ -248,7 +308,11 @@ export async function GET() {
feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100, feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100,
productMargins: productRows, productMargins: productRows,
aiInsight, aiInsight,
totals: { gross, refunds, orders: orderCount }, totals: {
gross,
refunds,
orders: orderCount,
},
}) })
} catch (e) { } catch (e) {
console.error("[business-metrics]", e) console.error("[business-metrics]", e)

View File

@ -199,7 +199,7 @@ export default function AiAnalyticsPage() {
}, [data?.customerExperience.sentimentMix.totals]) }, [data?.customerExperience.sentimentMix.totals])
return ( return (
<div className="space-y-6 md:space-y-8 max-w-[1400px] mx-auto"> <div className="w-full max-w-none space-y-5 md:space-y-7">
<div className="flex flex-col xl:flex-row xl:items-end xl:justify-between gap-4"> <div className="flex flex-col xl:flex-row xl:items-end xl:justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -212,14 +212,14 @@ export default function AiAnalyticsPage() {
Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys. Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0">
{RANGE_OPTIONS.map((option) => ( {RANGE_OPTIONS.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setRange(option.value)} onClick={() => setRange(option.value)}
className={cn( className={cn(
"rounded-xl border px-3 py-2 text-xs font-black transition", "shrink-0 rounded-xl border px-3 py-2 text-xs font-black transition",
range === option.value range === option.value
? isLight ? isLight
? "bg-indigo-600 border-indigo-600 text-white" ? "bg-indigo-600 border-indigo-600 text-white"
@ -249,7 +249,7 @@ export default function AiAnalyticsPage() {
{!loading && data && ( {!loading && data && (
<> <>
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <section className="grid grid-cols-1 sm:grid-cols-2 2xl:grid-cols-4 gap-3 md:gap-4">
<MetricCard <MetricCard
title="Direct AI upsell revenue" title="Direct AI upsell revenue"
value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)} value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)}
@ -280,8 +280,8 @@ export default function AiAnalyticsPage() {
/> />
</section> </section>
<section className="grid grid-cols-1 xl:grid-cols-3 gap-4 md:gap-6"> <section className="grid grid-cols-1 2xl:grid-cols-3 gap-4 md:gap-5">
<div className={cn("xl:col-span-2 rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("2xl:col-span-2 rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5">
<div> <div>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2>
@ -294,7 +294,8 @@ export default function AiAnalyticsPage() {
{timeline.length === 0 ? ( {timeline.length === 0 ? (
<EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState> <EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState>
) : ( ) : (
<div className="h-[300px] w-full min-w-0"> <div className="w-full overflow-x-auto">
<div className="h-[260px] min-w-[620px] md:h-[300px] md:min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={timeline} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}> <AreaChart data={timeline} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -308,10 +309,11 @@ export default function AiAnalyticsPage() {
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
)} )}
</div> </div>
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2>
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
Conservative sentiment from storefront customer messages. Unknown is kept out of the chart. Conservative sentiment from storefront customer messages. Unknown is kept out of the chart.
@ -319,7 +321,7 @@ export default function AiAnalyticsPage() {
{sentimentBars.every((row) => row.count === 0) ? ( {sentimentBars.every((row) => row.count === 0) ? (
<EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState> <EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState>
) : ( ) : (
<div className="h-[240px] w-full min-w-0"> <div className="h-[220px] w-full min-w-0 sm:h-[240px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}> <BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -338,13 +340,13 @@ export default function AiAnalyticsPage() {
</div> </div>
</section> </section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6"> <section className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2>
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed. Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed.
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<MetricCard title="Questions handled" value={data.automationOutcomes.questionsAnswered.toLocaleString()} helper="Customer questions in the selected window." accent={isLight ? "text-slate-900" : "text-white"} isLight={isLight} /> <MetricCard title="Questions handled" value={data.automationOutcomes.questionsAnswered.toLocaleString()} helper="Customer questions in the selected window." accent={isLight ? "text-slate-900" : "text-white"} isLight={isLight} />
<MetricCard title="Resolved by AI" value={formatPercent(data.automationOutcomes.resolutionRate)} helper={`${data.automationOutcomes.resolvedByAi.toLocaleString()} resolved events.`} accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} /> <MetricCard title="Resolved by AI" value={formatPercent(data.automationOutcomes.resolutionRate)} helper={`${data.automationOutcomes.resolvedByAi.toLocaleString()} resolved events.`} accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
<MetricCard title="Tickets created" value={data.automationOutcomes.ticketsCreated.toLocaleString()} helper="Escalations sent to human support." accent={isLight ? "text-rose-700" : "text-rose-300"} isLight={isLight} /> <MetricCard title="Tickets created" value={data.automationOutcomes.ticketsCreated.toLocaleString()} helper="Escalations sent to human support." accent={isLight ? "text-rose-700" : "text-rose-300"} isLight={isLight} />
@ -352,12 +354,13 @@ export default function AiAnalyticsPage() {
</div> </div>
</div> </div>
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Questions And Resolutions</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Questions And Resolutions</h2>
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
Timeline of demand, AI resolution, unresolved questions, and support tickets. Timeline of demand, AI resolution, unresolved questions, and support tickets.
</p> </p>
<div className="h-[260px] w-full min-w-0"> <div className="w-full overflow-x-auto">
<div className="h-[240px] min-w-[620px] md:h-[260px] md:min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}> <LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -373,10 +376,11 @@ export default function AiAnalyticsPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</div>
</section> </section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6"> <section className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Growth Signals</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Growth Signals</h2>
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
Product curiosity and repeated requests that can guide buying, merchandising, and product-page content. Product curiosity and repeated requests that can guide buying, merchandising, and product-page content.
@ -395,12 +399,12 @@ export default function AiAnalyticsPage() {
)} )}
</div> </div>
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}> <div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Attribution Health</h2> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Attribution Health</h2>
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet. Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<MetricCard title="Cart conversion" value={formatPercent(data.revenueImpact.aiCartConversionRate)} helper="Orders matched to AI cart actions." accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} /> <MetricCard title="Cart conversion" value={formatPercent(data.revenueImpact.aiCartConversionRate)} helper="Orders matched to AI cart actions." accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
<MetricCard title="Pending attribution" value={data.revenueImpact.pendingAttributionCount.toLocaleString()} helper="Clicks awaiting a matching Shopify order." accent={isLight ? "text-amber-700" : "text-amber-300"} isLight={isLight} /> <MetricCard title="Pending attribution" value={data.revenueImpact.pendingAttributionCount.toLocaleString()} helper="Clicks awaiting a matching Shopify order." accent={isLight ? "text-amber-700" : "text-amber-300"} isLight={isLight} />
<MetricCard title="Cross-sell revenue" value={formatMoney(data.revenueImpact.directCrossSellRevenue, data.revenueImpact.currency)} helper="Shown separately from upsell revenue." accent={isLight ? "text-cyan-700" : "text-cyan-300"} isLight={isLight} /> <MetricCard title="Cross-sell revenue" value={formatMoney(data.revenueImpact.directCrossSellRevenue, data.revenueImpact.currency)} helper="Shown separately from upsell revenue." accent={isLight ? "text-cyan-700" : "text-cyan-300"} isLight={isLight} />

View File

@ -74,7 +74,7 @@ export default async function DashboardLayout({
</header> </header>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar" data-lenis-prevent> <PageTransition className="p-3 sm:p-4 lg:p-6 2xl:p-7 overflow-y-auto custom-scrollbar" data-lenis-prevent>
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{children} {children}
</div> </div>

View File

@ -41,6 +41,8 @@ type TimeRange = "7days" | "30days" | "1year"
type BusinessMetricsPayload = { type BusinessMetricsPayload = {
currency: string currency: string
range?: TimeRange
comparisonLabel?: string
disclaimer: string disclaimer: string
kpis: { kpis: {
key: string key: string
@ -74,6 +76,21 @@ function formatMoney(amount: number, currency: string) {
} }
} }
function sinceForRange(range: TimeRange) {
const now = new Date()
if (range === "1year") {
return new Date(now.getFullYear(), now.getMonth() - 11, 1)
}
const days = range === "30days" ? 30 : 7
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))
}
function orderDate(order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string }) {
const raw = order.created_at ?? order.shopifyCreatedAt ?? order.createdAt ?? ""
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? null : date
}
function MiniSpark({ function MiniSpark({
data, data,
color, color,
@ -133,7 +150,7 @@ export default function DashboardPage() {
]) ])
try { try {
const bizRes = await fetch("/api/dashboard/business-metrics") const bizRes = await fetch(`/api/dashboard/business-metrics?range=${encodeURIComponent(timeRange)}`)
const j = await bizRes.json().catch(() => null) const j = await bizRes.json().catch(() => null)
if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) { if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) {
setBusiness(j as BusinessMetricsPayload) setBusiness(j as BusinessMetricsPayload)
@ -148,8 +165,13 @@ export default function DashboardPage() {
} }
const orders = ordersData.orders || [] const orders = ordersData.orders || []
const since = sinceForRange(timeRange)
const filteredOrders = orders.filter((order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string }) => {
const d = orderDate(order)
return d ? d >= since : false
})
const totalSales = const totalSales =
orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0 filteredOrders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0
const generateDateRange = (range: TimeRange) => { const generateDateRange = (range: TimeRange) => {
const dates = [] const dates = []
@ -173,13 +195,14 @@ export default function DashboardPage() {
const rangeLabels = generateDateRange(timeRange) const rangeLabels = generateDateRange(timeRange)
const salesByDate: Record<string, number> = {} const salesByDate: Record<string, number> = {}
orders.forEach((order: { created_at?: string; total_price?: string }) => { filteredOrders.forEach((order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string; total_price?: string }) => {
const orderDate = new Date(order.created_at ?? "") const createdAt = orderDate(order)
if (!createdAt) return
let label = "" let label = ""
if (timeRange === "1year") { if (timeRange === "1year") {
label = orderDate.toLocaleDateString("en-US", { month: "short", year: "2-digit" }) label = createdAt.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
} else { } else {
label = orderDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }) label = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric" })
} }
if (rangeLabels.includes(label)) { if (rangeLabels.includes(label)) {
@ -196,7 +219,7 @@ export default function DashboardPage() {
setSnapshot({ setSnapshot({
totalSales, totalSales,
products: productsData.products?.length ?? 0, products: productsData.products?.length ?? 0,
orders: orders.length, orders: filteredOrders.length,
customers: customersData.customers?.length ?? 0, customers: customersData.customers?.length ?? 0,
}) })
} catch (error) { } catch (error) {
@ -224,8 +247,8 @@ export default function DashboardPage() {
} }
return ( return (
<div className="space-y-6 md:space-y-8"> <div className="w-full max-w-none space-y-5 md:space-y-7">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4"> <div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-4">
<div> <div>
<h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}> <h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
Overview Overview
@ -272,13 +295,13 @@ export default function DashboardPage() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<div className="relative group"> <div className="relative group w-full sm:w-auto">
<select <select
value={timeRange} value={timeRange}
onChange={(e) => setTimeRange(e.target.value as TimeRange)} onChange={(e) => setTimeRange(e.target.value as TimeRange)}
className={cn( className={cn(
"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", "w-full 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 sm:w-auto",
isLight isLight
? "bg-white border-slate-200 text-slate-700 hover:border-slate-300 shadow-sm" ? "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",
@ -301,7 +324,7 @@ export default function DashboardPage() {
<button <button
type="button" type="button"
className={cn( className={cn(
"h-11 px-4 md:px-6 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all text-sm md:text-base shrink-0", "h-11 w-full px-4 md:px-6 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all text-sm md:text-base shrink-0 sm:w-auto",
isLight isLight
? "bg-indigo-600 text-white border-indigo-700 hover:bg-indigo-700 shadow-sm" ? "bg-indigo-600 text-white border-indigo-700 hover:bg-indigo-700 shadow-sm"
: "bg-white/10 text-white border-white/10 hover:bg-white/15 backdrop-blur-sm", : "bg-white/10 text-white border-white/10 hover:bg-white/15 backdrop-blur-sm",
@ -313,7 +336,7 @@ export default function DashboardPage() {
</div> </div>
{/* Financial KPI row — Image 2 style */} {/* Financial KPI row — Image 2 style */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 md:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 2xl:grid-cols-4 gap-3 md:gap-4">
{(business?.kpis ?? []).map((kpi) => ( {(business?.kpis ?? []).map((kpi) => (
<div <div
key={kpi.key} key={kpi.key}
@ -343,7 +366,7 @@ export default function DashboardPage() {
> >
<TrendingUp className={cn("w-3.5 h-3.5", kpi.changePct < 0 && "rotate-180")} /> <TrendingUp className={cn("w-3.5 h-3.5", kpi.changePct < 0 && "rotate-180")} />
{kpi.changePct >= 0 ? "+" : ""} {kpi.changePct >= 0 ? "+" : ""}
{kpi.changePct}% vs prior month {kpi.changePct}% vs {business?.comparisonLabel ?? "prior period"}
</p> </p>
</div> </div>
))} ))}
@ -363,10 +386,10 @@ export default function DashboardPage() {
{/* Profit bars + fee donut */} {/* Profit bars + fee donut */}
{business && ( {business && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
<div <div
className={cn( className={cn(
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[300px]", "p-4 md:p-5 rounded-xl md:rounded-2xl border min-h-[300px]",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)} )}
> >
@ -374,7 +397,8 @@ export default function DashboardPage() {
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
Based on gross revenue × assumed net margin ({business.disclaimer.slice(0, 80)}…) Based on gross revenue × assumed net margin ({business.disclaimer.slice(0, 80)}…)
</p> </p>
<div className="h-[220px] w-full min-w-0"> <div className="w-full overflow-x-auto">
<div className="h-[220px] min-w-[560px] md:min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={business.monthlyProfitBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}> <BarChart data={business.monthlyProfitBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -394,10 +418,11 @@ export default function DashboardPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</div>
<div <div
className={cn( className={cn(
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[300px]", "p-4 md:p-5 rounded-xl md:rounded-2xl border min-h-[300px]",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)} )}
> >
@ -437,7 +462,7 @@ export default function DashboardPage() {
)} )}
{/* Snapshot icons row — compact */} {/* Snapshot icons row — compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 md:gap-4">
{[ {[
{ label: "Live revenue (period)", value: formatMoney(snapshot.totalSales, currency), icon: TrendingUp }, { label: "Live revenue (period)", value: formatMoney(snapshot.totalSales, currency), icon: TrendingUp },
{ label: "Catalog size", value: String(snapshot.products), icon: Package }, { label: "Catalog size", value: String(snapshot.products), icon: Package },
@ -473,7 +498,7 @@ export default function DashboardPage() {
<div className={cn("px-4 md:px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2", isLight ? "border-slate-100" : "border-white/10")}> <div className={cn("px-4 md:px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2", isLight ? "border-slate-100" : "border-white/10")}>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>Product-level margins (est.)</h3> <h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>Product-level margins (est.)</h3>
</div> </div>
<div className="table-horizontal-scroll"> <div className="table-horizontal-scroll overflow-x-auto">
<table className="w-full text-sm min-w-[520px]"> <table className="w-full text-sm min-w-[520px]">
<thead> <thead>
<tr className={cn("text-left text-[10px] uppercase font-bold tracking-wider", isLight ? "bg-slate-50 text-slate-500" : "bg-white/[0.06] text-slate-400")}> <tr className={cn("text-left text-[10px] uppercase font-bold tracking-wider", isLight ? "bg-slate-50 text-slate-500" : "bg-white/[0.06] text-slate-400")}>
@ -523,25 +548,25 @@ export default function DashboardPage() {
<div <div
className={cn( className={cn(
"p-4 md:p-8 rounded-xl md:rounded-2xl border transition-all", "p-4 md:p-5 rounded-xl md:rounded-2xl border transition-all",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)} )}
> >
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6 md:mb-8"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-5 md:mb-6">
<div> <div>
<h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}> <h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}>
Recent Sales Trend Recent Sales Trend
</h3> </h3>
<p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>Revenue analysis over time</p> <p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>Revenue analysis over time</p>
</div> </div>
<div className="flex items-center gap-2 bg-slate-100 dark:bg-white/5 p-1 rounded-lg w-fit"> <div className="flex w-full items-center gap-2 overflow-x-auto rounded-lg bg-slate-100 p-1 dark:bg-white/5 md:w-fit md:overflow-visible">
{(["7days", "30days", "1year"] as const).map((r) => ( {(["7days", "30days", "1year"] as const).map((r) => (
<button <button
key={r} key={r}
type="button" type="button"
onClick={() => setTimeRange(r)} onClick={() => setTimeRange(r)}
className={cn( className={cn(
"px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all", "shrink-0 px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all",
timeRange === r timeRange === r
? "bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-sm" ? "bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white", : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white",
@ -553,7 +578,8 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="h-[250px] md:h-[350px] w-full min-w-0"> <div className="w-full overflow-x-auto">
<div className="h-[250px] min-w-[620px] md:h-[350px] md:min-w-0">
{loading ? ( {loading ? (
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5"> <div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" /> <Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
@ -616,5 +642,6 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -15,7 +15,14 @@ export function classifyStorefrontCustomerQuery(content: string): StorefrontInte
if (/\b(return|refund|exchange|wrong item|damaged|faulty)\b/.test(t)) { if (/\b(return|refund|exchange|wrong item|damaged|faulty)\b/.test(t)) {
return "Returns" return "Returns"
} }
if (/\b(product|sku|size|colour|color|material|fit|in stock|availability|price)\b/.test(t)) { if (
/\b(product|products|sku|size|sizes|colour|color|material|fit|fits|in stock|stock|available|availability|price|cost|buy|purchase|cart|variant|variants)\b/.test(
t,
) ||
/\b(shoe|shoes|sandal|sandals|boot|boots|sneaker|sneakers|slipper|slippers|sock|socks|shirt|shirts|dress|dresses|earbud|earbuds|case|cases)\b/.test(
t,
)
) {
return "Product Qs" return "Product Qs"
} }
if (/\b(shipping|delivery|courier|dispatch|address change|postcode|zip)\b/.test(t)) { if (/\b(shipping|delivery|courier|dispatch|address change|postcode|zip)\b/.test(t)) {