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) {
try {
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 === "upsell_shown") row.upsellShown += 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 === "neutral") row.neutralSentiment += 1
if (event.sentiment === "negative") row.negativeSentiment += 1
@ -496,14 +519,18 @@ export async function GET(req: Request) {
const bucket = classifyStorefrontCustomerQuery(message.content)
intentCounts[bucket] += 1
if (bucket === "Product Qs") {
const name = productQuestionLabel(message.content)
productQuestionCounts.set(name, {
name,
count: (productQuestionCounts.get(name)?.count ?? 0) + 1,
})
incrementProductQuestion(productQuestionCounts, productQuestionLabel(message.content))
}
pendingByConversation.set(message.conversationId, message.createdAt)
} 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)
if (!pendingAt) continue
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
@ -513,12 +540,18 @@ export async function GET(req: Request) {
}
const manualLatencyMinutes: number[] = []
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
if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min)
const pendingManualByCounterparty = new Map<string, Date>()
for (const message of chatMessages) {
if (message.receiverId === vendorUserId && message.senderId !== vendorUserId) {
pendingManualByCounterparty.set(message.senderId, message.createdAt)
continue
}
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 unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length
const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length
const cartClicks = analyticsEvents.filter((event) =>
["add_to_cart_clicked", "upsell_clicked", "cross_sell_clicked"].includes(event.eventType),
).length
const cartTypes = cartEventTypes()
const cartEvents = analyticsEvents.filter((event) => cartTypes.has(event.eventType))
const cartClicks = cartEvents.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 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 sentimentTotals = {
@ -549,7 +590,11 @@ export async function GET(req: Request) {
const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
const aiMedianSeconds = median(aiLatencySeconds)
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 topProductQuestions = Array.from(productQuestionCounts.values())
.sort((a, b) => b.count - a.count)
@ -573,12 +618,12 @@ export async function GET(req: Request) {
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
aiCartConversionRate: pct(directOrderCount, conversionBase),
aiCartConversionRate: pct(cartAttributedOrderCount || directOrderCount, conversionBase),
averageAiInfluencedOrderValue:
revenueAttribution.assistedRows.length > 0
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
: 0,
pendingAttributionCount: Math.max(0, cartClicks - directOrderCount),
pendingAttributionCount: Math.max(0, conversionBase - (cartAttributedOrderCount || directOrderCount)),
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
@ -588,7 +633,7 @@ export async function GET(req: Request) {
createdAt: row.createdAt.toISOString(),
})),
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: {
sentimentMix: {
@ -613,7 +658,7 @@ export async function GET(req: Request) {
resolvedByAi,
ticketsCreated,
aiMedianSeconds: Math.round(aiMedianSeconds),
manualMedianMinutes: Math.round(manualMedianMinutes * 10) / 10,
manualMedianMinutes: Math.round(manualBenchmarkMinutes * 10) / 10,
sampleAiPairs: aiLatencySeconds.length,
sampleManualPairs: manualLatencyMinutes.length,
hoursSavedEstimate,

View File

@ -1,5 +1,5 @@
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 { prisma } from "@/lib/prisma"
import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore"
@ -8,17 +8,51 @@ 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
type DashboardRange = "7days" | "30days" | "1year"
function parseRange(input: string | null): DashboardRange {
if (input === "30days" || input === "1year" || input === "7days") return input
return "7days"
}
function monthLabel(isoMonth: string): string {
const [y, m] = isoMonth.split("-").map(Number)
return format(new Date(y!, (m ?? 1) - 1, 1), "MMM")
function rangeWindow(range: DashboardRange, now = new Date()) {
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")
},
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 = {
@ -33,7 +67,7 @@ function parseLineItems(raw: unknown): LineItemLike[] {
return raw as LineItemLike[]
}
export async function GET() {
export async function GET(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
@ -48,12 +82,17 @@ export async function GET() {
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({
where: {
storeId: store.id,
OR: [{ shopifyCreatedAt: { gte: since } }, { shopifyCreatedAt: null, createdAt: { gte: since } }],
OR: [
{ shopifyCreatedAt: { gte: config.previousSince } },
{ shopifyCreatedAt: null, createdAt: { gte: config.previousSince } },
],
},
select: {
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 refundByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
let gross = 0
let refunds = 0
let previousGross = 0
let previousRefunds = 0
type ProductAgg = {
title: string
@ -89,17 +130,25 @@ export async function GET() {
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")
const isCurrentPeriod = d >= config.since
const isPreviousPeriod = d >= config.previousSince && d < config.since
const mk = config.bucketKey(d)
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
if (isCurrentPeriod) {
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
}
} else if (isPreviousPeriod) {
if (isRefundLike) previousRefunds += Math.abs(amt)
else previousGross += amt
}
const firstHalf = d < midpoint
if (!isCurrentPeriod) continue
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
@ -122,10 +171,19 @@ export async function GET() {
}
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 avgMarginPct =
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>) =>
keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100)
@ -144,9 +202,9 @@ export async function GET() {
) / 100,
)
function pctChange(series: number[]): number {
const a = series[series.length - 2] ?? 0
const b = series[series.length - 1] ?? 0
function pctChangeValue(current: number, previous: number): number {
const a = previous
const b = current
if (a === 0) return b > 0 ? 100 : 0
return Math.round(((b - a) / a) * 1000) / 10
}
@ -156,21 +214,21 @@ export async function GET() {
key: "netProfit",
label: "Est. net profit",
value: netProfitEstimate,
changePct: pctChange(profitSpark),
changePct: pctChangeValue(netProfitEstimate, previousNetProfitEstimate),
sparkline: profitSpark,
},
{
key: "grossRevenue",
label: "Gross revenue",
value: gross,
changePct: pctChange(grossSpark),
changePct: pctChangeValue(gross, previousGross),
sparkline: grossSpark,
},
{
key: "fees",
label: "Est. fees",
value: estimatedFees,
changePct: pctChange(feeSpark),
changePct: pctChangeValue(estimatedFees, previousFees),
sparkline: feeSpark,
},
{
@ -178,13 +236,13 @@ export async function GET() {
label: "Avg. margin (est.)",
value: avgMarginPct,
suffix: "%",
changePct: avgMarginPct > 30 ? 1.2 : -0.8,
changePct: pctChangeValue(avgMarginPct, previousAvgMarginPct),
sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1),
},
]
const monthlyProfitBars = keys.map((k) => ({
month: monthLabel(k),
month: config.bucketLabel(k),
profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100,
}))
@ -240,6 +298,8 @@ export async function GET() {
return NextResponse.json({
currency,
range,
comparisonLabel: config.previousLabel,
disclaimer:
"Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.",
kpis,
@ -248,7 +308,11 @@ export async function GET() {
feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100,
productMargins: productRows,
aiInsight,
totals: { gross, refunds, orders: orderCount },
totals: {
gross,
refunds,
orders: orderCount,
},
})
} catch (e) {
console.error("[business-metrics]", e)

View File

@ -199,7 +199,7 @@ export default function AiAnalyticsPage() {
}, [data?.customerExperience.sentimentMix.totals])
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>
<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.
</p>
</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) => (
<button
key={option.value}
type="button"
onClick={() => setRange(option.value)}
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
? isLight
? "bg-indigo-600 border-indigo-600 text-white"
@ -249,7 +249,7 @@ export default function AiAnalyticsPage() {
{!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
title="Direct AI upsell revenue"
value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)}
@ -280,8 +280,8 @@ export default function AiAnalyticsPage() {
/>
</section>
<section className="grid grid-cols-1 xl:grid-cols-3 gap-4 md:gap-6">
<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")}>
<section className="grid grid-cols-1 2xl:grid-cols-3 gap-4 md:gap-5">
<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>
<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 ? (
<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%">
<AreaChart data={timeline} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -307,11 +308,12 @@ export default function AiAnalyticsPage() {
<Area type="monotone" dataKey="directCrossSellRevenue" name="Direct cross-sell" stackId="1" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.3} />
</AreaChart>
</ResponsiveContainer>
</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>
<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.
@ -319,7 +321,7 @@ export default function AiAnalyticsPage() {
{sentimentBars.every((row) => row.count === 0) ? (
<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%">
<BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -338,13 +340,13 @@ export default function AiAnalyticsPage() {
</div>
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<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")}>
<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-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>
<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.
</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="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} />
@ -352,12 +354,13 @@ 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")}>Questions And Resolutions</h2>
<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.
</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%">
<LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -371,12 +374,13 @@ export default function AiAnalyticsPage() {
<Line type="monotone" dataKey="ticketsCreated" name="Tickets" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<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")}>
<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-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>
<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.
@ -395,12 +399,12 @@ export default function AiAnalyticsPage() {
)}
</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>
<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.
</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="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} />

View File

@ -74,7 +74,7 @@ export default async function DashboardLayout({
</header>
<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">
{children}
</div>

View File

@ -41,6 +41,8 @@ type TimeRange = "7days" | "30days" | "1year"
type BusinessMetricsPayload = {
currency: string
range?: TimeRange
comparisonLabel?: string
disclaimer: string
kpis: {
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({
data,
color,
@ -133,7 +150,7 @@ export default function DashboardPage() {
])
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)
if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) {
setBusiness(j as BusinessMetricsPayload)
@ -148,8 +165,13 @@ export default function DashboardPage() {
}
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 =
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 dates = []
@ -173,13 +195,14 @@ export default function DashboardPage() {
const rangeLabels = generateDateRange(timeRange)
const salesByDate: Record<string, number> = {}
orders.forEach((order: { created_at?: string; total_price?: string }) => {
const orderDate = new Date(order.created_at ?? "")
filteredOrders.forEach((order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string; total_price?: string }) => {
const createdAt = orderDate(order)
if (!createdAt) return
let label = ""
if (timeRange === "1year") {
label = orderDate.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
label = createdAt.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
} else {
label = orderDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })
label = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric" })
}
if (rangeLabels.includes(label)) {
@ -196,7 +219,7 @@ export default function DashboardPage() {
setSnapshot({
totalSales,
products: productsData.products?.length ?? 0,
orders: orders.length,
orders: filteredOrders.length,
customers: customersData.customers?.length ?? 0,
})
} catch (error) {
@ -224,8 +247,8 @@ export default function DashboardPage() {
}
return (
<div className="space-y-6 md:space-y-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="w-full max-w-none space-y-5 md:space-y-7">
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-4">
<div>
<h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
Overview
@ -272,13 +295,13 @@ export default function DashboardPage() {
</Link>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative group">
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<div className="relative group w-full sm:w-auto">
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
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
? "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",
@ -301,7 +324,7 @@ export default function DashboardPage() {
<button
type="button"
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
? "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",
@ -313,7 +336,7 @@ export default function DashboardPage() {
</div>
{/* 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) => (
<div
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")} />
{kpi.changePct >= 0 ? "+" : ""}
{kpi.changePct}% vs prior month
{kpi.changePct}% vs {business?.comparisonLabel ?? "prior period"}
</p>
</div>
))}
@ -363,10 +386,10 @@ export default function DashboardPage() {
{/* Profit bars + fee donut */}
{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
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",
)}
>
@ -374,7 +397,8 @@ export default function DashboardPage() {
<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)}…)
</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%">
<BarChart data={business.monthlyProfitBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
@ -392,12 +416,13 @@ export default function DashboardPage() {
<Bar dataKey="profit" fill="#22c55e" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div
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",
)}
>
@ -437,7 +462,7 @@ export default function DashboardPage() {
)}
{/* 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: "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")}>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>Product-level margins (est.)</h3>
</div>
<div className="table-horizontal-scroll">
<div className="table-horizontal-scroll overflow-x-auto">
<table className="w-full text-sm min-w-[520px]">
<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")}>
@ -523,25 +548,25 @@ export default function DashboardPage() {
<div
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",
)}
>
<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>
<h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}>
Recent Sales Trend
</h3>
<p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>Revenue analysis over time</p>
</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) => (
<button
key={r}
type="button"
onClick={() => setTimeRange(r)}
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
? "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",
@ -553,7 +578,8 @@ export default function DashboardPage() {
</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 ? (
<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" />
@ -614,6 +640,7 @@ export default function DashboardPage() {
</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)) {
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"
}
if (/\b(shipping|delivery|courier|dispatch|address change|postcode|zip)\b/.test(t)) {