fix: filters, UI
This commit is contained in:
parent
23ab998311
commit
6765e2bbf4
@ -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,
|
||||
|
||||
@ -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)
|
||||
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,8 +130,11 @@ 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 (isCurrentPeriod) {
|
||||
if (isRefundLike) {
|
||||
refunds += Math.abs(amt)
|
||||
if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt)
|
||||
@ -98,8 +142,13 @@ export async function GET() {
|
||||
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)
|
||||
|
||||
@ -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} />
|
||||
@ -308,10 +309,11 @@ export default function AiAnalyticsPage() {
|
||||
</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} />
|
||||
@ -373,10 +376,11 @@ export default function AiAnalyticsPage() {
|
||||
</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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
@ -394,10 +418,11 @@ export default function DashboardPage() {
|
||||
</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" />
|
||||
@ -616,5 +642,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user