revamp: ai analytics

This commit is contained in:
root 2026-05-12 00:12:10 +05:00
parent a1646f3af9
commit c5ed06697e
18 changed files with 2218 additions and 1050 deletions

View File

@ -0,0 +1,31 @@
CREATE TABLE "AiAnalyticsEvent" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"conversationId" TEXT,
"messageId" TEXT,
"eventType" TEXT NOT NULL,
"source" TEXT NOT NULL,
"productId" TEXT,
"variantId" TEXT,
"quantity" INTEGER,
"amount" DOUBLE PRECISION,
"currency" TEXT,
"sentiment" TEXT,
"resolutionStatus" TEXT,
"orderId" TEXT,
"attributionType" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiAnalyticsEvent_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AiAnalyticsEvent"
ADD CONSTRAINT "AiAnalyticsEvent_storeId_fkey"
FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "AiAnalyticsEvent_storeId_eventType_createdAt_idx" ON "AiAnalyticsEvent"("storeId", "eventType", "createdAt");
CREATE INDEX "AiAnalyticsEvent_storeId_createdAt_idx" ON "AiAnalyticsEvent"("storeId", "createdAt");
CREATE INDEX "AiAnalyticsEvent_conversationId_idx" ON "AiAnalyticsEvent"("conversationId");
CREATE INDEX "AiAnalyticsEvent_productId_idx" ON "AiAnalyticsEvent"("productId");
CREATE INDEX "AiAnalyticsEvent_orderId_idx" ON "AiAnalyticsEvent"("orderId");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { GET } from "./route"
vi.mock("@/auth", () => ({
auth: vi.fn(),
}))
vi.mock("@/lib/dashboard/vendorActiveStore", () => ({
getVendorActiveStore: vi.fn(),
}))
vi.mock("@/lib/prisma", () => ({
prisma: {
storefrontChatMessage: { findMany: vi.fn() },
aiAnalyticsEvent: { findMany: vi.fn() },
storefrontChatConversation: { findMany: vi.fn() },
cachedOrder: { findMany: vi.fn() },
chatMessage: { findMany: vi.fn() },
},
}))
beforeEach(() => {
vi.clearAllMocks()
})
describe("GET /api/dashboard/ai-analytics", () => {
it("returns business outcome analytics instead of technical model KPIs", async () => {
const { auth } = await import("@/auth")
const { getVendorActiveStore } = await import("@/lib/dashboard/vendorActiveStore")
const { prisma } = await import("@/lib/prisma")
vi.mocked(auth).mockResolvedValue({ user: { id: "user-1", role: "VENDOR" } } as never)
vi.mocked(getVendorActiveStore).mockResolvedValue({ id: "store-1", userId: "user-1" } as never)
vi.mocked(prisma.storefrontChatMessage.findMany).mockResolvedValue([
{
senderType: "CUSTOMER",
createdAt: new Date("2026-05-01T10:00:00Z"),
content: "Do you have school shoes?",
conversationId: "conv-1",
},
{
senderType: "AI",
createdAt: new Date("2026-05-01T10:00:03Z"),
content: "Yes, we have school shoes.",
conversationId: "conv-1",
},
] as never)
vi.mocked(prisma.aiAnalyticsEvent.findMany).mockResolvedValue([
{
id: "event-1",
conversationId: "conv-1",
messageId: "m1",
eventType: "customer_question",
source: "storefront_chat",
productId: null,
variantId: null,
quantity: null,
amount: null,
currency: null,
sentiment: "neutral",
resolutionStatus: null,
orderId: null,
attributionType: null,
metadata: null,
createdAt: new Date("2026-05-01T10:00:00Z"),
},
{
id: "event-2",
conversationId: "conv-1",
messageId: "m2",
eventType: "resolved_by_ai",
source: "storefront_chat",
productId: null,
variantId: null,
quantity: null,
amount: null,
currency: null,
sentiment: null,
resolutionStatus: "resolved_by_ai",
orderId: null,
attributionType: null,
metadata: null,
createdAt: new Date("2026-05-01T10:00:03Z"),
},
] as never)
vi.mocked(prisma.storefrontChatConversation.findMany).mockResolvedValue([
{
id: "conv-1",
visitorId: "visitor-1",
customerEmail: null,
boundShopifyOrderId: null,
createdAt: new Date("2026-05-01T09:59:00Z"),
updatedAt: new Date("2026-05-01T10:00:03Z"),
},
] as never)
vi.mocked(prisma.cachedOrder.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.chatMessage.findMany).mockResolvedValue([] as never)
const res = await GET(new Request("http://localhost/api/dashboard/ai-analytics?range=30d"))
const json = await res.json()
expect(res.status).toBe(200)
expect(json.modelEvaluation).toBeUndefined()
expect(json.revenueImpact).toMatchObject({
directUpsellRevenue: 0,
aiAssistedRevenue: 0,
})
expect(json.customerExperience.sentimentMix.totals.neutral).toBe(1)
expect(json.automationOutcomes.questionsAnswered).toBe(1)
expect(Array.isArray(json.timeline)).toBe(true)
})
})

View File

@ -1,10 +1,75 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { format, startOfMonth, 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"
import { classifyStorefrontCustomerQuery } from "@/lib/dashboard/storefrontQueryClassification" import { classifyStorefrontCustomerQuery } from "@/lib/dashboard/storefrontQueryClassification"
import { buildModelEvaluationSnapshot } from "@/lib/dashboard/aiModelEvaluationSnapshot" import { bucketKey, emptyTimeline, getAnalyticsWindow, type AnalyticsGrain } from "@/lib/dashboard/analyticsTimeline"
import { computeAiRevenueAttribution } from "@/lib/analytics/revenueAttribution"
type StorefrontMessageRow = {
senderType: "CUSTOMER" | "AI"
createdAt: Date
content: string
conversationId: string
}
type AnalyticsEventRow = {
id: string
conversationId: string | null
messageId: string | null
eventType: string
source: string
productId: string | null
variantId: string | null
quantity: number | null
amount: number | null
currency: string | null
sentiment: string | null
resolutionStatus: string | null
orderId: string | null
attributionType: string | null
metadata: unknown
createdAt: Date
}
type ConversationRow = {
id: string
visitorId: string
customerEmail: string | null
boundShopifyOrderId: string | null
createdAt: Date
updatedAt: Date
}
type CachedOrderRow = {
shopifyOrderId: string
orderNumber: string
email: string | null
totalPrice: string
currency: string | null
lineItems: unknown
shopifyCreatedAt: Date | null
createdAt: Date
}
type ChatMessageRow = {
senderId: string
receiverId: string
createdAt: Date
}
type PrismaWithAnalytics = typeof prisma & {
aiAnalyticsEvent: {
findMany: (args: {
where: {
storeId: string
createdAt: { gte: Date; lte: Date }
}
select: Record<string, boolean>
orderBy: { createdAt: "asc" }
}) => Promise<AnalyticsEventRow[]>
}
}
function median(nums: number[]): number { function median(nums: number[]): number {
if (nums.length === 0) return 0 if (nums.length === 0) return 0
@ -13,20 +78,37 @@ function median(nums: number[]): number {
return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2 return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2
} }
function monthKeys(monthCount: number): string[] { function pct(part: number, total: number): number {
const keys: string[] = [] if (total <= 0) return 0
for (let i = monthCount - 1; i >= 0; i--) { return Math.round((part / total) * 1000) / 10
keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM"))
}
return keys
} }
function monthLabel(isoMonth: string): string { function money(amount: number): number {
const [y, m] = isoMonth.split("-").map(Number) return Math.round(amount * 100) / 100
return format(new Date(y!, (m ?? 1) - 1, 1), "MMM")
} }
export async function GET() { function addToTimeline<T extends { key: string }>(
timeline: T[],
date: Date,
grain: AnalyticsGrain,
update: (row: T) => void,
) {
const key = bucketKey(date, grain)
const row = timeline.find((item) => item.key === key)
if (row) update(row)
}
function productQuestionLabel(content: string): string {
return (
content
.replace(/[^\w\s-]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 70) || "Product question"
)
}
export async function GET(req: Request) {
try { try {
const session = await auth() const session = await auth()
if (!session?.user?.id) { if (!session?.user?.id) {
@ -41,65 +123,149 @@ export async function GET() {
return NextResponse.json({ message: "No active store" }, { status: 400 }) return NextResponse.json({ message: "No active store" }, { status: 400 })
} }
const { searchParams } = new URL(req.url)
const window = getAnalyticsWindow({
range: searchParams.get("range"),
grain: searchParams.get("grain"),
})
const vendorUserId = store.userId const vendorUserId = store.userId
const since = startOfMonth(subMonths(new Date(), 5)) const prismaWithAnalytics = prisma as PrismaWithAnalytics
const [ const [storefrontMessages, analyticsEvents, conversations, cachedOrders, chatMessages]: [
storefrontMessages, StorefrontMessageRow[],
gmailAutoReplies, AnalyticsEventRow[],
attributedConversations, ConversationRow[],
chatMessages, CachedOrderRow[],
] = await Promise.all([ ChatMessageRow[],
prisma.storefrontChatMessage.findMany({ ] =
where: { await Promise.all([
conversation: { storeId: store.id }, prisma.storefrontChatMessage.findMany({
createdAt: { gte: since }, where: {
}, conversation: { storeId: store.id },
select: { createdAt: { gte: window.since, lte: window.until },
senderType: true, },
createdAt: true, select: {
content: true, senderType: true,
conversationId: true, createdAt: true,
}, content: true,
orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], conversationId: true,
}), },
prisma.gmailInboundAutoReply.findMany({ orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }],
where: { storeId: store.id, createdAt: { gte: since } }, }),
select: { createdAt: true }, prismaWithAnalytics.aiAnalyticsEvent.findMany({
}), where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
prisma.storefrontChatConversation.findMany({ select: {
where: { id: true,
storeId: store.id, conversationId: true,
boundShopifyOrderId: { not: null }, messageId: true,
updatedAt: { gte: subMonths(new Date(), 1) }, eventType: true,
}, source: true,
select: { productId: true,
id: true, variantId: true,
visitorId: true, quantity: true,
customerEmail: true, amount: true,
boundShopifyOrderId: true, currency: true,
updatedAt: true, sentiment: true,
}, resolutionStatus: true,
orderBy: { updatedAt: "desc" }, orderId: true,
take: 25, attributionType: true,
}), metadata: true,
prisma.chatMessage.findMany({ createdAt: true,
where: { storeId: store.id, createdAt: { gte: since } }, },
select: { senderId: true, receiverId: true, createdAt: true }, orderBy: { createdAt: "asc" },
orderBy: { createdAt: "asc" }, }),
take: 8000, prisma.storefrontChatConversation.findMany({
}), where: { storeId: store.id, updatedAt: { gte: window.since } },
]) select: {
id: true,
visitorId: true,
customerEmail: true,
boundShopifyOrderId: true,
createdAt: true,
updatedAt: true,
},
orderBy: { updatedAt: "asc" },
}),
prisma.cachedOrder.findMany({
where: {
storeId: store.id,
OR: [
{ shopifyCreatedAt: { gte: window.since, lte: window.until } },
{ createdAt: { gte: window.since, lte: window.until } },
],
},
select: {
shopifyOrderId: true,
orderNumber: true,
email: true,
totalPrice: true,
currency: true,
lineItems: true,
shopifyCreatedAt: true,
createdAt: true,
},
}),
prisma.chatMessage.findMany({
where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
select: { senderId: true, receiverId: true, createdAt: true },
orderBy: { createdAt: "asc" },
take: 8000,
}),
])
const keys = monthKeys(6) const revenueAttribution = computeAiRevenueAttribution({
const aiShareByMonth: Record<string, { ai: number; cust: number }> = Object.fromEntries( events: analyticsEvents,
keys.map((k) => [k, { ai: 0, cust: 0 }]), conversations,
) orders: cachedOrders,
const gmailByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0])) })
for (const g of gmailAutoReplies) { const timeline = emptyTimeline(window.labels, window.grain, {
const k = format(startOfMonth(g.createdAt), "yyyy-MM") directUpsellRevenue: 0,
if (k in gmailByMonth) gmailByMonth[k] += 1 directCrossSellRevenue: 0,
aiAssistedRevenue: 0,
questions: 0,
resolvedByAi: 0,
unresolvedQuestions: 0,
ticketsCreated: 0,
upsellShown: 0,
upsellClicked: 0,
cartActions: 0,
positiveSentiment: 0,
neutralSentiment: 0,
negativeSentiment: 0,
urgentSentiment: 0,
})
for (const event of analyticsEvents) {
addToTimeline(timeline, event.createdAt, window.grain, (row) => {
if (event.eventType === "customer_question") row.questions += 1
if (event.eventType === "resolved_by_ai") row.resolvedByAi += 1
if (event.eventType === "unresolved_question") row.unresolvedQuestions += 1
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 (event.sentiment === "positive") row.positiveSentiment += 1
if (event.sentiment === "neutral") row.neutralSentiment += 1
if (event.sentiment === "negative") row.negativeSentiment += 1
if (event.sentiment === "urgent") row.urgentSentiment += 1
})
}
for (const row of revenueAttribution.directRows) {
addToTimeline(timeline, row.createdAt, window.grain, (bucket) => {
if (row.attributionType === "direct_upsell") bucket.directUpsellRevenue += row.amount
if (row.attributionType === "direct_cross_sell") bucket.directCrossSellRevenue += row.amount
})
}
for (const row of revenueAttribution.assistedRows) {
addToTimeline(timeline, row.createdAt, window.grain, (bucket) => {
bucket.aiAssistedRevenue += row.amount
})
}
for (const row of timeline) {
row.directUpsellRevenue = money(row.directUpsellRevenue)
row.directCrossSellRevenue = money(row.directCrossSellRevenue)
row.aiAssistedRevenue = money(row.aiAssistedRevenue)
} }
const intentCounts: Record<string, number> = { const intentCounts: Record<string, number> = {
@ -109,144 +275,157 @@ export async function GET() {
Shipping: 0, Shipping: 0,
General: 0, General: 0,
} }
const productQuestionCounts = new Map<string, { name: string; count: number }>()
const pendingByConversation = new Map<string, Date>()
const aiLatencySeconds: number[] = [] const aiLatencySeconds: number[] = []
const aiLatencyByMonth: Record<string, number[]> = Object.fromEntries(keys.map((k) => [k, []]))
let pendingCustomer: { conversationId: string; createdAt: Date } | null = null
for (const m of storefrontMessages) { for (const message of storefrontMessages) {
const monthKey = format(startOfMonth(m.createdAt), "yyyy-MM") if (message.senderType === "CUSTOMER") {
if (monthKey in aiShareByMonth) { const bucket = classifyStorefrontCustomerQuery(message.content)
if (m.senderType === "AI") aiShareByMonth[monthKey]!.ai += 1 intentCounts[bucket] += 1
if (m.senderType === "CUSTOMER") aiShareByMonth[monthKey]!.cust += 1 if (bucket === "Product Qs") {
} const name = productQuestionLabel(message.content)
productQuestionCounts.set(name, {
if (m.senderType === "CUSTOMER") { name,
intentCounts[classifyStorefrontCustomerQuery(m.content)] += 1 count: (productQuestionCounts.get(name)?.count ?? 0) + 1,
pendingCustomer = { conversationId: m.conversationId, createdAt: m.createdAt } })
} else if (m.senderType === "AI" && pendingCustomer?.conversationId === m.conversationId) { }
const sec = (m.createdAt.getTime() - pendingCustomer.createdAt.getTime()) / 1000 pendingByConversation.set(message.conversationId, message.createdAt)
aiLatencySeconds.push(sec) } else if (message.senderType === "AI") {
const mk = format(startOfMonth(m.createdAt), "yyyy-MM") const pendingAt = pendingByConversation.get(message.conversationId)
if (mk in aiLatencyByMonth) aiLatencyByMonth[mk]!.push(sec) if (!pendingAt) continue
pendingCustomer = null const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
if (sec >= 0 && sec <= 3600) aiLatencySeconds.push(sec)
pendingByConversation.delete(message.conversationId)
} }
} }
const automationSeries = keys.map((key) => {
const { ai, cust } = aiShareByMonth[key] ?? { ai: 0, cust: 0 }
const vol = ai + cust
const storefrontAiSharePct = vol > 0 ? Math.round((ai / vol) * 1000) / 10 : 0
return {
monthKey: key,
month: monthLabel(key),
storefrontAiSharePct,
gmailAutoReplies: gmailByMonth[key] ?? 0,
volumes: { aiMessages: ai, customerMessages: cust },
}
})
const manualLatencyMinutes: number[] = [] const manualLatencyMinutes: number[] = []
const manualLatencyByMonth: Record<string, number[]> = Object.fromEntries(keys.map((k) => [k, []]))
for (let i = 0; i < chatMessages.length - 1; i++) { for (let i = 0; i < chatMessages.length - 1; i++) {
const a = chatMessages[i]! const a = chatMessages[i]!
const b = chatMessages[i + 1]! const b = chatMessages[i + 1]!
if ( if (a.receiverId === vendorUserId && a.senderId !== vendorUserId && b.senderId === vendorUserId) {
a.receiverId === vendorUserId &&
a.senderId !== vendorUserId &&
b.senderId === vendorUserId
) {
const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000 const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000
manualLatencyMinutes.push(min) if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min)
const mk = format(startOfMonth(b.createdAt), "yyyy-MM")
if (mk in manualLatencyByMonth) manualLatencyByMonth[mk]!.push(min)
} }
} }
const responseTimeByMonth = keys.map((k) => ({ const questions =
month: monthLabel(k), analyticsEvents.filter((event) => event.eventType === "customer_question").length ||
aiMinutes: Math.round((median(aiLatencyByMonth[k] ?? []) / 60) * 10) / 10, storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length
manualMinutes: Math.round(median(manualLatencyByMonth[k] ?? []) * 10) / 10, const aiAnswers =
})) analyticsEvents.filter((event) => event.eventType === "ai_answered").length ||
storefrontMessages.filter((m) => m.senderType === "AI").length
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 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 currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP"
const orderIds = attributedConversations const sentimentTotals = {
.map((c) => c.boundShopifyOrderId) positive: analyticsEvents.filter((event) => event.sentiment === "positive").length,
.filter(Boolean) as string[] neutral: analyticsEvents.filter((event) => event.sentiment === "neutral").length,
negative: analyticsEvents.filter((event) => event.sentiment === "negative").length,
const cachedOrders = urgent: analyticsEvents.filter((event) => event.sentiment === "urgent").length,
orderIds.length > 0 unknown: analyticsEvents.filter((event) => event.sentiment === "unknown").length,
? await prisma.cachedOrder.findMany({ }
where: { const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
storeId: store.id, const aiMedianSeconds = median(aiLatencySeconds)
shopifyOrderId: { in: orderIds }, const manualMedianMinutes = median(manualLatencyMinutes)
}, const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60)
select: { const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
shopifyOrderId: true, const topProductQuestions = Array.from(productQuestionCounts.values())
totalPrice: true, .sort((a, b) => b.count - a.count)
currency: true, .slice(0, 8)
orderNumber: true, const convertedProductIds = new Set(revenueAttribution.directRows.map((row) => row.productId).filter(Boolean))
}, const highInterestProducts = topProductQuestions
}) .filter((row) => !convertedProductIds.has(row.name))
: [] .slice(0, 5)
const orderByShopifyId = new Map(cachedOrders.map((o) => [o.shopifyOrderId, o]))
const attributedRows = attributedConversations
.map((conv) => {
const oid = conv.boundShopifyOrderId
if (!oid) return null
const o = orderByShopifyId.get(oid)
const amt = o ? parseFloat(o.totalPrice || "0") : null
const currency = o?.currency ?? "GBP"
const hint =
conv.customerEmail?.split("@")[0]?.slice(0, 2).toUpperCase() ??
conv.visitorId.slice(0, 2).toUpperCase()
return {
id: conv.id,
initials: hint.padEnd(2, "?"),
label:
conv.customerEmail?.replace(/(@.).+$/, "$1***") ??
`Visitor ${conv.visitorId.slice(-6)}`,
summary: o?.orderNumber ? `Order ${o.orderNumber}` : "Linked storefront session → order",
amount: amt,
currency,
channel: "AI" as const,
updatedAt: conv.updatedAt.toISOString(),
}
})
.filter(Boolean)
const attributedTotal = attributedRows.reduce((sum, r) => sum + (r?.amount ?? 0), 0)
const signalEvents = storefrontMessages.length + gmailAutoReplies.length
const modelEvaluation = buildModelEvaluationSnapshot(store.id, {
signalEvents,
monthLabels: automationSeries.map((r) => r.month),
})
return NextResponse.json({ return NextResponse.json({
storeId: store.id, storeId: store.id,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
modelEvaluation, range: window.range,
automationSeries, grain: window.grain,
messageVolumeByType: Object.entries(intentCounts).map(([name, count]) => ({ name, count })), window: {
responseTime: { since: window.since.toISOString(),
aiMedianSeconds: Math.round(median(aiLatencySeconds)), until: window.until.toISOString(),
manualMedianMinutes: Math.round(median(manualLatencyMinutes) * 10) / 10, },
revenueImpact: {
currency,
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
aiCartConversionRate: pct(directOrderCount, conversionBase),
averageAiInfluencedOrderValue:
revenueAttribution.assistedRows.length > 0
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
: 0,
pendingAttributionCount: Math.max(0, cartClicks - directOrderCount),
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
})),
assistedRows: revenueAttribution.assistedRows.slice(0, 12).map((row) => ({
...row,
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.",
},
customerExperience: {
sentimentMix: {
totals: sentimentTotals,
percentages: {
positive: pct(sentimentTotals.positive, sentimentTotal),
neutral: pct(sentimentTotals.neutral, sentimentTotal),
negative: pct(sentimentTotals.negative, sentimentTotal),
urgent: pct(sentimentTotals.urgent, sentimentTotal),
unknown: pct(sentimentTotals.unknown, sentimentTotal),
},
},
unresolvedQuestionRate: pct(unresolvedQuestions, questions),
escalationRate: pct(ticketsCreated, questions),
unresolvedQuestions,
ticketsCreated,
negativeOrUrgent: sentimentTotals.negative + sentimentTotals.urgent,
},
automationOutcomes: {
questionsAnswered: questions,
aiAnswers,
resolvedByAi,
ticketsCreated,
aiMedianSeconds: Math.round(aiMedianSeconds),
manualMedianMinutes: Math.round(manualMedianMinutes * 10) / 10,
sampleAiPairs: aiLatencySeconds.length, sampleAiPairs: aiLatencySeconds.length,
sampleManualPairs: manualLatencyMinutes.length, sampleManualPairs: manualLatencyMinutes.length,
byMonth: responseTimeByMonth, hoursSavedEstimate,
resolutionRate: pct(resolvedByAi, questions),
}, },
attributedSupportSales: { demandSignals: {
totalAmount: Math.round(attributedTotal * 100) / 100, curiousProductQuestionCount: intentCounts["Product Qs"],
currency: cachedOrders[0]?.currency ?? "GBP", topProductQuestions,
rows: attributedRows.slice(0, 12), highInterestProducts,
questionMix: Object.entries(intentCounts).map(([name, count]) => ({ name, count })),
},
timeline,
technicalDiagnostics: {
hiddenByDefault: true,
storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length,
storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length,
analyticsEvents: analyticsEvents.length,
cachedOrders: cachedOrders.length,
}, },
totals: { totals: {
storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length, questions,
storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER") aiAnswers,
.length, cartClicks,
gmailAutoReplies: gmailAutoReplies.length, cartOpenCount,
}, },
}) })
} catch (e) { } catch (e) {

View File

@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { storefrontChatEventSchema } from "@/lib/api-schemas/public"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
import { logAiAnalyticsEvent } from "@/lib/analytics/aiEvents"
function normalizeShopDomain(input: string) {
return input.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/$/, "")
}
function shopVariants(cleanDomain: string) {
if (!cleanDomain) return []
const v = new Set<string>()
v.add(cleanDomain)
v.add(`${cleanDomain}/`)
v.add(`https://${cleanDomain}`)
v.add(`http://${cleanDomain}`)
return Array.from(v)
}
export async function POST(req: Request) {
try {
const raw = await req.json().catch(() => ({}))
const parsed = storefrontChatEventSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { shop, visitorId, type, productId, variantId, sourceMessageId } = parsed.data
const stores = await prisma.store.findMany({
where: { shopifyDomain: { in: shopVariants(normalizeShopDomain(shop)) } },
select: { id: true, storefrontChatEnabled: true },
})
const store = stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
if (!store || !store.storefrontChatEnabled) {
return NextResponse.json({ success: true })
}
const conversation = await prisma.storefrontChatConversation.findFirst({
where: { storeId: store.id, visitorId },
select: { id: true },
})
if (conversation) {
void logAiAnalyticsEvent({
storeId: store.id,
conversationId: conversation.id,
messageId: sourceMessageId ?? null,
eventType: type,
productId: productId ?? null,
variantId: variantId ?? null,
metadata: { source: "widget_action" },
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("[STOREFRONT_CHAT_EVENTS_POST]", error)
return NextResponse.json({ success: true })
}
}

View File

@ -25,6 +25,12 @@ import {
selectVariantForCart, selectVariantForCart,
} from "@/lib/storefront-chat/cartLinks" } from "@/lib/storefront-chat/cartLinks"
import { productSearchHitToActionProduct, searchProductsForStore } from "@/lib/ai/tools/productTools" import { productSearchHitToActionProduct, searchProductsForStore } from "@/lib/ai/tools/productTools"
import {
classifyAiResolution,
classifyCustomerSentiment,
logAiAnalyticsEvent,
logAiAnalyticsEvents,
} from "@/lib/analytics/aiEvents"
function normalizeShopDomain(input: string) { function normalizeShopDomain(input: string) {
return input return input
@ -423,6 +429,39 @@ export async function POST(req: Request) {
select: { id: true, createdAt: true, senderType: true, content: true, actions: true } select: { id: true, createdAt: true, senderType: true, content: true, actions: true }
}) })
void logAiAnalyticsEvents([
{
storeId: store.id,
conversationId: conversation.id,
messageId: customerMessage.id,
eventType: "customer_question",
sentiment: classifyCustomerSentiment(customerContent),
metadata: {
actionType: action?.type ?? null,
contentPreview: customerContent.slice(0, 500),
},
createdAt: customerMessage.createdAt,
},
...(action
? [
{
storeId: store.id,
conversationId: conversation.id,
messageId: customerMessage.id,
eventType:
action.type === "upsell_suggestion"
? ("upsell_clicked" as const)
: ("add_to_cart_clicked" as const),
productId: action.productId,
variantId: action.variantId ?? null,
quantity: action.quantity,
metadata: { sourceMessageId: action.sourceMessageId ?? null },
createdAt: customerMessage.createdAt,
},
]
: []),
])
const pendingCartProduct = parsePendingCartProduct(conversation.l1State) const pendingCartProduct = parsePendingCartProduct(conversation.l1State)
const existingCartLines = parseCartLines(conversation.l1State) const existingCartLines = parseCartLines(conversation.l1State)
const cartResponse = action const cartResponse = action
@ -463,6 +502,50 @@ export async function POST(req: Request) {
select: { id: true, createdAt: true, senderType: true, content: true, actions: true }, select: { id: true, createdAt: true, senderType: true, content: true, actions: true },
}) })
const openCartAction = cartResponse.actions.find((a) => a.type === "open_cart")
const openCartProduct = openCartAction?.product
void logAiAnalyticsEvents([
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "ai_answered",
resolutionStatus: "resolved_by_ai",
productId: openCartProduct?.productId ?? null,
variantId: openCartProduct?.variantId ?? null,
quantity: openCartProduct?.quantity ?? null,
metadata: {
contentPreview: cartResponse.content.slice(0, 500),
cartLines: cartResponse.cartLines,
},
createdAt: aiMessage.createdAt,
},
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "cart_link_generated",
productId: openCartProduct?.productId ?? null,
variantId: openCartProduct?.variantId ?? null,
quantity: openCartProduct?.quantity ?? null,
metadata: {
cartUrl: openCartAction?.cartUrl ?? null,
cartLines: cartResponse.cartLines,
},
createdAt: aiMessage.createdAt,
},
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "resolved_by_ai",
productId: openCartProduct?.productId ?? null,
variantId: openCartProduct?.variantId ?? null,
metadata: { reason: "cart_permalink_generated" },
createdAt: aiMessage.createdAt,
},
])
await prisma.storefrontChatConversation.update({ await prisma.storefrontChatConversation.update({
where: { id: conversation.id }, where: { id: conversation.id },
data: { data: {
@ -581,6 +664,68 @@ export async function POST(req: Request) {
select: { id: true, createdAt: true, senderType: true, content: true, actions: true } select: { id: true, createdAt: true, senderType: true, content: true, actions: true }
}) })
const resolutionStatus = classifyAiResolution({
content: aiContent,
usedTools: ai.meta?.usedTools,
module: ai.meta?.module,
})
const upsellActions = aiActions.filter((a) => a.type === "upsell_suggestion")
void logAiAnalyticsEvents([
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "ai_answered",
resolutionStatus,
metadata: {
module: ai.meta?.module ?? null,
usedTools: ai.meta?.usedTools ?? [],
contentPreview: aiContent.slice(0, 500),
},
createdAt: aiMessage.createdAt,
},
...(resolutionStatus === "resolved_by_ai"
? [
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "resolved_by_ai" as const,
resolutionStatus,
metadata: { module: ai.meta?.module ?? null },
createdAt: aiMessage.createdAt,
},
]
: resolutionStatus === "needs_follow_up"
? [
{
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "unresolved_question" as const,
resolutionStatus,
metadata: { module: ai.meta?.module ?? null },
createdAt: aiMessage.createdAt,
},
]
: []),
...upsellActions.map((upsell) => ({
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "upsell_shown" as const,
productId: upsell.product.productId,
variantId: upsell.product.variantId ?? null,
quantity: upsell.product.quantity ?? 1,
amount: upsell.product.price ? Number(upsell.product.price) || null : null,
metadata: {
title: upsell.product.title,
productType: upsell.product.productType ?? null,
},
createdAt: aiMessage.createdAt,
})),
])
if (aiContent === ai.content) { if (aiContent === ai.content) {
await prisma.storefrontChatConversation.update({ await prisma.storefrontChatConversation.update({
where: { id: conversation.id }, where: { id: conversation.id },
@ -612,6 +757,18 @@ export async function POST(req: Request) {
status: "SENT", status: "SENT",
}, },
}) })
void logAiAnalyticsEvent({
storeId: store.id,
conversationId: conversation.id,
messageId: aiMessage.id,
eventType: "ticket_created",
resolutionStatus: "escalated",
metadata: {
to: ticketTo,
subject: ticketSubject,
module: ai.meta?.module ?? null,
},
})
} }
} catch (ticketErr) { } catch (ticketErr) {
console.error("[STOREFRONT_TICKET_CREATE_ERR]", ticketErr) console.error("[STOREFRONT_TICKET_CREATE_ERR]", ticketErr)

View File

@ -7,82 +7,107 @@ import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell,
Legend, Legend,
Line, Line,
LineChart, LineChart,
ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
Cell,
} from "recharts" } from "recharts"
import { Loader2, Sparkles } from "lucide-react" import { Loader2, Sparkles } from "lucide-react"
import { useTheme } from "@/components/ThemeProvider" import { useTheme } from "@/components/ThemeProvider"
import { chartTooltipProps } from "@/lib/charts/rechartsTooltip" import { chartTooltipProps } from "@/lib/charts/rechartsTooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type ModelEvaluationPayload = { type TimelineRow = {
modelName: string key: string
pipelineVersion: string label: string
evaluationWindow: string directUpsellRevenue: number
sampleSize: number directCrossSellRevenue: number
accuracyPct: number aiAssistedRevenue: number
precisionPct: number questions: number
recallPct: number resolvedByAi: number
f1Pct: number unresolvedQuestions: number
confusionMatrix: { ticketsCreated: number
truePositive: number upsellShown: number
falsePositive: number upsellClicked: number
falseNegative: number cartActions: number
trueNegative: number positiveSentiment: number
} neutralSentiment: number
monthlyF1Pct: { month: string; f1: number }[] negativeSentiment: number
urgentSentiment: number
} }
type AiAnalyticsPayload = { type AiAnalyticsPayload = {
modelEvaluation: ModelEvaluationPayload generatedAt: string
automationSeries: { range: string
month: string grain: string
storefrontAiSharePct: number revenueImpact: {
gmailAutoReplies: number currency: string
volumes: { aiMessages: number; customerMessages: number } directUpsellRevenue: number
}[] directCrossSellRevenue: number
messageVolumeByType: { name: string; count: number }[] aiAssistedRevenue: number
responseTime: { aiCartConversionRate: number
averageAiInfluencedOrderValue: number
pendingAttributionCount: number
methodology: string
}
customerExperience: {
sentimentMix: {
totals: Record<"positive" | "neutral" | "negative" | "urgent" | "unknown", number>
percentages: Record<"positive" | "neutral" | "negative" | "urgent" | "unknown", number>
}
unresolvedQuestionRate: number
escalationRate: number
unresolvedQuestions: number
ticketsCreated: number
negativeOrUrgent: number
}
automationOutcomes: {
questionsAnswered: number
aiAnswers: number
resolvedByAi: number
ticketsCreated: number
aiMedianSeconds: number aiMedianSeconds: number
manualMedianMinutes: number manualMedianMinutes: number
sampleAiPairs: number sampleAiPairs: number
sampleManualPairs: number sampleManualPairs: number
byMonth: { month: string; aiMinutes: number; manualMinutes: number }[] hoursSavedEstimate: number
resolutionRate: number
} }
attributedSupportSales: { demandSignals: {
totalAmount: number curiousProductQuestionCount: number
currency: string topProductQuestions: { name: string; count: number }[]
rows: { highInterestProducts: { name: string; count: number }[]
id: string questionMix: { name: string; count: number }[]
initials: string
label: string
summary: string
amount: number | null
currency: string
channel: "AI"
updatedAt: string
}[]
} }
totals: { timeline: TimelineRow[]
technicalDiagnostics: {
hiddenByDefault: boolean
storefrontAiMessages: number storefrontAiMessages: number
storefrontCustomerMessages: number storefrontCustomerMessages: number
gmailAutoReplies: number analyticsEvents: number
cachedOrders: number
} }
} }
const INTENT_COLORS: Record<string, string> = { const RANGE_OPTIONS = [
WISMO: "#3b82f6", { value: "today", label: "Today" },
Returns: "#f97316", { value: "7d", label: "7 days" },
"Product Qs": "#a855f7", { value: "30d", label: "30 days" },
Shipping: "#22c55e", { value: "90d", label: "90 days" },
General: "#94a3b8", { value: "12m", label: "12 months" },
{ value: "year", label: "Year" },
{ value: "all", label: "All" },
] as const
const SENTIMENT_COLORS = {
positive: "#22c55e",
neutral: "#94a3b8",
negative: "#f97316",
urgent: "#ef4444",
} }
function formatMoney(amount: number, currency: string) { function formatMoney(amount: number, currency: string) {
@ -93,23 +118,62 @@ function formatMoney(amount: number, currency: string) {
} }
} }
function formatPercent(value: number) {
return `${value.toFixed(value % 1 === 0 ? 0 : 1)}%`
}
function EmptyState({ children, isLight }: { children: string; isLight: boolean }) {
return (
<div className={cn("rounded-xl border p-6 text-sm text-center", isLight ? "border-slate-100 bg-slate-50 text-slate-500" : "border-white/10 bg-white/[0.04] text-slate-400")}>
{children}
</div>
)
}
function MetricCard({
title,
value,
helper,
accent,
isLight,
}: {
title: string
value: string
helper: string
accent: string
isLight: boolean
}) {
return (
<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")}>
<p className={cn("text-[11px] uppercase tracking-widest font-black", isLight ? "text-slate-500" : "text-slate-400")}>
{title}
</p>
<p className={cn("text-2xl md:text-3xl font-black mt-2 tabular-nums", accent)}>{value}</p>
<p className={cn("text-xs mt-2 leading-relaxed", isLight ? "text-slate-500" : "text-slate-400")}>{helper}</p>
</div>
)
}
export default function AiAnalyticsPage() { export default function AiAnalyticsPage() {
const { theme } = useTheme() const { theme } = useTheme()
const isLight = theme === "light" const isLight = theme === "light"
const [range, setRange] = useState("30d")
const [data, setData] = useState<AiAnalyticsPayload | null>(null) const [data, setData] = useState<AiAnalyticsPayload | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setLoading(true)
setError(null)
;(async () => { ;(async () => {
try { try {
const res = await fetch("/api/dashboard/ai-analytics") const res = await fetch(`/api/dashboard/ai-analytics?range=${encodeURIComponent(range)}`)
const json = await res.json().catch(() => null) const json = await res.json().catch(() => null)
if (!res.ok) throw new Error(json?.message || "Failed to load analytics") if (!res.ok) throw new Error(json?.message || "Failed to load analytics")
if (!cancelled) setData(json) if (!cancelled) setData(json)
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load") if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load analytics")
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
} }
@ -117,63 +181,58 @@ export default function AiAnalyticsPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [range])
const chartMuted = isLight ? "#64748b" : "#94a3b8" const chartMuted = isLight ? "#64748b" : "#94a3b8"
const gridStroke = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)" const gridStroke = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)"
const tt = useMemo(() => chartTooltipProps(isLight), [isLight]) const tt = useMemo(() => chartTooltipProps(isLight), [isLight])
const timeline = data?.timeline ?? []
const barData = useMemo( const sentimentBars = useMemo(() => {
() => const totals = data?.customerExperience.sentimentMix.totals
(data?.messageVolumeByType ?? []).map((r) => ({ if (!totals) return []
...r, return [
fill: INTENT_COLORS[r.name] ?? "#6366f1", { name: "Positive", count: totals.positive, fill: SENTIMENT_COLORS.positive },
})), { name: "Neutral", count: totals.neutral, fill: SENTIMENT_COLORS.neutral },
[data?.messageVolumeByType], { name: "Negative", count: totals.negative, fill: SENTIMENT_COLORS.negative },
) { name: "Urgent", count: totals.urgent, fill: SENTIMENT_COLORS.urgent },
]
const responseBars = useMemo(() => { }, [data?.customerExperience.sentimentMix.totals])
const rows = data?.responseTime.byMonth ?? []
return rows.map((r) => ({
month: r.month,
AI: r.aiMinutes > 0 ? r.aiMinutes : 0,
Manual: r.manualMinutes > 0 ? r.manualMinutes : 0,
}))
}, [data?.responseTime.byMonth])
return ( return (
<div className="space-y-6 md:space-y-8 max-w-[1400px] mx-auto"> <div className="space-y-6 md:space-y-8 max-w-[1400px] mx-auto">
<div className="flex flex-col sm:flex-row sm:items-end sm: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">
<Sparkles className={cn("w-7 h-7", isLight ? "text-indigo-600" : "text-indigo-400")} /> <Sparkles className={cn("w-7 h-7", isLight ? "text-indigo-600" : "text-indigo-400")} />
<h1 <h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
className={cn( AI Business Analytics
"text-2xl md:text-3xl font-extrabold tracking-tight",
isLight ? "text-slate-900" : "text-white",
)}
>
AI Analytics
</h1> </h1>
</div> </div>
<p className={cn("text-sm md:text-base max-w-2xl", isLight ? "text-slate-500" : "text-slate-400")}> <p className={cn("text-sm md:text-base max-w-3xl", isLight ? "text-slate-500" : "text-slate-400")}>
Live metrics from storefront AI replies, Gmail automation, JUNO Chat response times, and Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.
storefront sessions linked to Shopify orders.
</p> </p>
</div> </div>
{data && ( <div className="flex flex-wrap gap-2">
<div {RANGE_OPTIONS.map((option) => (
className={cn( <button
"text-xs font-semibold rounded-xl px-4 py-2 border", key={option.value}
isLight ? "bg-white border-slate-200 text-slate-600" : "bg-white/5 border-white/10 text-slate-300", type="button"
)} onClick={() => setRange(option.value)}
> className={cn(
<span className="opacity-70">Last 6 months · AI msgs </span> "rounded-xl border px-3 py-2 text-xs font-black transition",
<span className="text-emerald-500">{data.totals.storefrontAiMessages}</span> range === option.value
<span className="opacity-70"> · Gmail auto </span> ? isLight
<span className="text-sky-400">{data.totals.gmailAutoReplies}</span> ? "bg-indigo-600 border-indigo-600 text-white"
</div> : "bg-indigo-500 border-indigo-400 text-white"
)} : isLight
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-50"
: "bg-white/5 border-white/10 text-slate-300 hover:bg-white/10",
)}
>
{option.label}
</button>
))}
</div>
</div> </div>
{loading && ( {loading && (
@ -181,418 +240,186 @@ export default function AiAnalyticsPage() {
<Loader2 className="w-10 h-10 animate-spin text-indigo-400" /> <Loader2 className="w-10 h-10 animate-spin text-indigo-400" />
</div> </div>
)} )}
{error && ( {error && (
<div <div className={cn("rounded-2xl border p-6 text-sm font-medium", isLight ? "bg-rose-50 border-rose-100 text-rose-800" : "bg-rose-950/40 border-rose-900/50 text-rose-100")}>
className={cn(
"rounded-2xl border p-6 text-sm font-medium",
isLight ? "bg-rose-50 border-rose-100 text-rose-800" : "bg-rose-950/40 border-rose-900/50 text-rose-100",
)}
>
{error} {error}
</div> </div>
)} )}
{!loading && data && ( {!loading && data && (
<> <>
<div <section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
className={cn( <MetricCard
"p-4 md:p-6 rounded-xl md:rounded-2xl border", title="Direct AI upsell revenue"
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)}
)} helper="Only counts AI-suggested products later found in matching Shopify orders."
> accent={isLight ? "text-emerald-700" : "text-emerald-400"}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6"> isLight={isLight}
<div> />
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}> <MetricCard
Model performance title="AI-assisted revenue"
</h3> value={formatMoney(data.revenueImpact.aiAssistedRevenue, data.revenueImpact.currency)}
<p className={cn("text-xs mt-1 max-w-xl", isLight ? "text-slate-500" : "text-slate-400")}> helper="Full order revenue after prior AI engagement in the same customer/order window."
Intent routing and reply-quality classification tracked against assistant-handled accent={isLight ? "text-indigo-700" : "text-indigo-300"}
conversations on your storefront and Gmail automation pipeline. isLight={isLight}
</p> />
</div> <MetricCard
<div title="Unresolved questions"
className={cn( value={data.customerExperience.unresolvedQuestions.toLocaleString()}
"text-[11px] font-semibold space-y-1 rounded-xl px-4 py-3 border shrink-0", helper={`${formatPercent(data.customerExperience.unresolvedQuestionRate)} unresolved rate across customer questions.`}
isLight ? "bg-slate-50 border-slate-100 text-slate-600" : "bg-white/[0.06] border-white/10 text-slate-300", accent={isLight ? "text-orange-700" : "text-orange-300"}
)} isLight={isLight}
> />
<p> <MetricCard
<span className={cn("opacity-70 font-bold uppercase tracking-wide", isLight ? "" : "")}>Model </span> title="Hours saved estimate"
<span className={isLight ? "text-slate-900" : "text-white"}>{data.modelEvaluation.modelName}</span> value={`${data.automationOutcomes.hoursSavedEstimate.toLocaleString()}h`}
</p> helper="Estimated from AI answer volume and median manual response time where samples exist."
<p> accent={isLight ? "text-sky-700" : "text-sky-300"}
<span className="opacity-70 font-bold uppercase tracking-wide">Release </span> isLight={isLight}
<span className={isLight ? "text-slate-900" : "text-white"}>{data.modelEvaluation.pipelineVersion}</span> />
</p> </section>
<p className="opacity-80">{data.modelEvaluation.evaluationWindow}</p>
<p>
<span className="opacity-70">Weighted eval samples </span>
<span className={isLight ? "text-indigo-600" : "text-indigo-300"}>
{data.modelEvaluation.sampleSize.toLocaleString()}
</span>
</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-6"> <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")}>
[ <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5">
{ label: "Accuracy", value: data.modelEvaluation.accuracyPct, accent: "text-emerald-500" }, <div>
{ label: "Precision", value: data.modelEvaluation.precisionPct, accent: "text-sky-500" }, <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2>
{ label: "Recall", value: data.modelEvaluation.recallPct, accent: "text-violet-500" }, <p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>{data.revenueImpact.methodology}</p>
{ label: "F1 score", value: data.modelEvaluation.f1Pct, accent: "text-indigo-500" },
] as const
).map((m) => (
<div
key={m.label}
className={cn(
"rounded-xl border p-4",
isLight ? "bg-slate-50/90 border-slate-100" : "bg-white/[0.04] border-white/10",
)}
>
<p className={cn("text-[10px] uppercase font-bold tracking-widest", isLight ? "text-slate-500" : "text-slate-400")}>
{m.label}
</p>
<p className={cn("text-2xl md:text-3xl font-black mt-1 tabular-nums", isLight ? "text-slate-900" : "text-white")}>
{m.value}
<span className={cn("text-lg align-top ml-0.5", m.accent)}>%</span>
</p>
</div> </div>
))} <span className={cn("text-[11px] font-black rounded-lg px-2 py-1", isLight ? "bg-slate-100 text-slate-600" : "bg-white/10 text-slate-300")}>
</div> {data.grain} view
</span>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> </div>
<div className="min-h-[220px]"> {timeline.length === 0 ? (
<h4 className={cn("text-sm font-bold mb-3", isLight ? "text-slate-800" : "text-slate-200")}> <EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState>
F1 score trend ) : (
</h4> <div className="h-[300px] w-full min-w-0">
<div className="h-[200px] w-full min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data.modelEvaluation.monthlyF1Pct} margin={{ top: 8, right: 8, left: -18, 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} />
<XAxis <XAxis dataKey="label" tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} dy={8} />
dataKey="month" <YAxis tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} <Tooltip {...tt} formatter={(value, name) => [formatMoney(Number(value ?? 0), data.revenueImpact.currency), String(name)]} />
axisLine={false} <Legend wrapperStyle={{ fontSize: 11, fontWeight: 700, color: chartMuted }} />
tickLine={false} <Area type="monotone" dataKey="aiAssistedRevenue" name="AI-assisted" stackId="1" stroke="#6366f1" fill="#6366f1" fillOpacity={0.22} />
dy={8} <Area type="monotone" dataKey="directUpsellRevenue" name="Direct upsell" stackId="1" stroke="#22c55e" fill="#22c55e" fillOpacity={0.35} />
/> <Area type="monotone" dataKey="directCrossSellRevenue" name="Direct cross-sell" stackId="1" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.3} />
<YAxis </AreaChart>
domain={[78, 99]}
tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip {...tt} formatter={(v) => [`${Number(v ?? 0)}%`, "F1"]} />
<Line type="monotone" dataKey="f1" stroke="#6366f1" strokeWidth={3} dot={{ r: 4 }} activeDot={{ r: 6 }} />
</LineChart>
</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")}>
<h4 className={cn("text-sm font-bold mb-3", isLight ? "text-slate-800" : "text-slate-200")}> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2>
Confusion matrix (held-out) <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
</h4> Conservative sentiment from storefront customer messages. Unknown is kept out of the chart.
<div className="flex justify-end gap-3 text-[10px] font-bold uppercase tracking-wide mb-2 pr-1"> </p>
<span className={cn("w-[calc(50%-6px)] text-center", isLight ? "text-slate-500" : "text-slate-400")}> {sentimentBars.every((row) => row.count === 0) ? (
Predicted relevant <EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState>
</span> ) : (
<span className={cn("w-[calc(50%-6px)] text-center", isLight ? "text-slate-500" : "text-slate-400")}> <div className="h-[240px] w-full min-w-0">
Predicted not relevant <ResponsiveContainer width="100%" height="100%">
</span> <BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<XAxis dataKey="name" tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} dy={8} />
<YAxis tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
<Tooltip {...tt} />
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{sentimentBars.map((row) => (
<Cell key={row.name} fill={row.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div> </div>
<div className="flex gap-3"> )}
<div </div>
className={cn( </section>
"flex flex-col justify-center gap-10 py-2 w-24 shrink-0 text-[10px] font-bold uppercase tracking-wide leading-tight",
isLight ? "text-slate-500" : "text-slate-400", <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")}>
> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2>
<span>Actual relevant</span> <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
<span>Actual not relevant</span> Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed.
</div> </p>
<div className="flex-1 grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div <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} />
className={cn( <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} />
"rounded-xl border p-4 text-center", <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} />
isLight ? "bg-emerald-50 border-emerald-100 text-emerald-900" : "bg-emerald-500/10 border-emerald-500/25 text-emerald-100", <MetricCard title="AI response median" value={`${data.automationOutcomes.aiMedianSeconds}s`} helper={`Manual median: ${data.automationOutcomes.manualMedianMinutes.toFixed(1)} min.`} accent={isLight ? "text-sky-700" : "text-sky-300"} isLight={isLight} />
)} </div>
> </div>
<p className="text-[10px] uppercase opacity-80 mb-1">True positive</p>
<p className="text-2xl font-black tabular-nums">{data.modelEvaluation.confusionMatrix.truePositive}</p> <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")}>
<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">
<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} />
<XAxis dataKey="label" tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} dy={8} />
<YAxis tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
<Tooltip {...tt} />
<Legend wrapperStyle={{ fontSize: 11, fontWeight: 700, color: chartMuted }} />
<Line type="monotone" dataKey="questions" name="Questions" stroke="#6366f1" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="resolvedByAi" name="Resolved" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="unresolvedQuestions" name="Unresolved" stroke="#f97316" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="ticketsCreated" name="Tickets" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</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")}>
<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.
</p>
{data.demandSignals.topProductQuestions.length === 0 ? (
<EmptyState isLight={isLight}>Not enough product-question data yet.</EmptyState>
) : (
<div className="space-y-2">
{data.demandSignals.topProductQuestions.map((row) => (
<div key={row.name} className={cn("flex items-center justify-between gap-3 rounded-xl border px-3 py-2", isLight ? "bg-slate-50 border-slate-100" : "bg-white/[0.04] border-white/10")}>
<span className={cn("text-sm font-bold truncate", isLight ? "text-slate-800" : "text-slate-200")}>{row.name}</span>
<span className={cn("text-xs font-black rounded-lg px-2 py-1", isLight ? "bg-indigo-100 text-indigo-700" : "bg-indigo-500/20 text-indigo-200")}>{row.count}</span>
</div> </div>
<div ))}
className={cn(
"rounded-xl border p-4 text-center",
isLight ? "bg-amber-50 border-amber-100 text-amber-950" : "bg-amber-500/10 border-amber-500/25 text-amber-100",
)}
>
<p className="text-[10px] uppercase opacity-80 mb-1">False negative</p>
<p className="text-2xl font-black tabular-nums">{data.modelEvaluation.confusionMatrix.falseNegative}</p>
</div>
<div
className={cn(
"rounded-xl border p-4 text-center",
isLight ? "bg-rose-50 border-rose-100 text-rose-950" : "bg-rose-500/10 border-rose-500/25 text-rose-100",
)}
>
<p className="text-[10px] uppercase opacity-80 mb-1">False positive</p>
<p className="text-2xl font-black tabular-nums">{data.modelEvaluation.confusionMatrix.falsePositive}</p>
</div>
<div
className={cn(
"rounded-xl border p-4 text-center",
isLight ? "bg-slate-50 border-slate-200 text-slate-800" : "bg-white/[0.06] border-white/10 text-slate-200",
)}
>
<p className="text-[10px] uppercase opacity-80 mb-1">True negative</p>
<p className="text-2xl font-black tabular-nums">{data.modelEvaluation.confusionMatrix.trueNegative}</p>
</div>
</div>
</div> </div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div
className={cn(
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px]",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)} )}
>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>
AI share of storefront traffic
</h3>
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
AI messages ÷ (AI + customer messages) per month. Gmail auto-replies shown in tooltip.
</p>
<div className="h-[240px] w-full min-w-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.automationSeries} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<defs>
<linearGradient id="aiShareFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={isLight ? 0.35 : 0.25} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<XAxis
dataKey="month"
tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }}
axisLine={false}
tickLine={false}
dy={8}
/>
<YAxis
tickFormatter={(v) => `${v}%`}
domain={[0, 100]}
tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
{...tt}
formatter={(value, _name, item) => {
const g = (item as { payload?: { gmailAutoReplies?: number } })?.payload
?.gmailAutoReplies
return [`${Number(value ?? 0)}% (Gmail auto: ${g ?? 0})`, "AI share"]
}}
labelFormatter={(l) => String(l)}
/>
<ReferenceLine
y={80}
stroke={isLight ? "#cbd5e1" : "rgba(148,163,184,0.5)"}
strokeDasharray="4 4"
/>
<Area
type="monotone"
dataKey="storefrontAiSharePct"
stroke="#16a34a"
strokeWidth={2}
fillOpacity={1}
fill="url(#aiShareFill)"
name="storefrontAiSharePct"
/>
</AreaChart>
</ResponsiveContainer>
</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")}>
className={cn( <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Attribution Health</h2>
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px]",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)}
>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>
Customer topics (storefront)
</h3>
<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")}>
Classified from customer messages via keyword rules (WISMO, returns, products, shipping). Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.
</p> </p>
<div className="h-[240px] w-full min-w-0"> <div className="grid grid-cols-2 gap-3">
<ResponsiveContainer width="100%" height="100%"> <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} />
<BarChart layout="vertical" data={barData} margin={{ left: 4, right: 16, top: 4, bottom: 4 }}> <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} />
<CartesianGrid strokeDasharray="3 3" horizontal stroke={gridStroke} /> <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} />
<XAxis type="number" tick={{ fill: chartMuted, fontSize: 10 }} axisLine={false} tickLine={false} /> <MetricCard title="Avg influenced order" value={formatMoney(data.revenueImpact.averageAiInfluencedOrderValue, data.revenueImpact.currency)} helper="Average value for AI-assisted orders." accent={isLight ? "text-indigo-700" : "text-indigo-300"} isLight={isLight} />
<YAxis
type="category"
dataKey="name"
width={88}
tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
{...tt}
cursor={{ fill: isLight ? "rgba(99,102,241,0.06)" : "rgba(255,255,255,0.05)" }}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]} barSize={18}>
{barData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div> </div>
</div> </div>
</section>
<div <details className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
className={cn( <summary className={cn("cursor-pointer text-sm font-black", isLight ? "text-slate-800" : "text-slate-200")}>
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px]", Technical Diagnostics
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", </summary>
)} <div className={cn("grid grid-cols-2 md:grid-cols-4 gap-3 mt-4 text-sm", isLight ? "text-slate-600" : "text-slate-300")}>
> <p>AI messages: {data.technicalDiagnostics.storefrontAiMessages}</p>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}> <p>Customer messages: {data.technicalDiagnostics.storefrontCustomerMessages}</p>
Response time: AI vs manual <p>Analytics events: {data.technicalDiagnostics.analyticsEvents}</p>
</h3> <p>Cached orders: {data.technicalDiagnostics.cachedOrders}</p>
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
AI = median seconds from customer message to AI reply (storefront). Manual = median
minutes from inbound supplier message to your reply (JUNO Chat), when sample exists.
</p>
<div className="h-[240px] w-full min-w-0">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={responseBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<XAxis
dataKey="month"
tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }}
axisLine={false}
tickLine={false}
dy={8}
/>
<YAxis
tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}m`}
/>
<Legend wrapperStyle={{ fontSize: 11, fontWeight: 600, color: chartMuted }} />
<Tooltip
{...tt}
formatter={(value, name) =>
name === "AI"
? [`${Number(value ?? 0)} min (median)`, "AI"]
: [`${Number(value ?? 0)} min (median)`, "Manual"]
}
/>
<Bar dataKey="AI" fill="#22c55e" radius={[4, 4, 0, 0]} />
<Bar dataKey="Manual" fill="#94a3b8" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<p className={cn("text-[10px] mt-2 font-medium", isLight ? "text-slate-400" : "text-slate-500")}>
Samples: AI pairs {data.responseTime.sampleAiPairs} · Manual pairs{" "}
{data.responseTime.sampleManualPairs}. Overall medians: AI{" "}
{(data.responseTime.aiMedianSeconds / 60).toFixed(1)} min · Manual{" "}
{data.responseTime.manualMedianMinutes.toFixed(1)} min
</p>
</div> </div>
</details>
<div
className={cn(
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px] flex flex-col",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
)}
>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>
Revenue linked after AI storefront sessions
</h3>
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
Orders pinned during verified storefront chat (last ~30 days). Totals use cached Shopify
orders for your active store.
</p>
<div className="flex-1 overflow-y-auto space-y-2 min-h-[180px] max-h-[260px] pr-1 custom-scrollbar">
{data.attributedSupportSales.rows.length === 0 ? (
<p className={cn("text-sm italic py-8 text-center", isLight ? "text-slate-400" : "text-slate-500")}>
No bound orders yet — when customers verify and attach an order in chat,
conversions appear here.
</p>
) : (
data.attributedSupportSales.rows.map((row) => (
<div
key={row.id}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border",
isLight ? "border-slate-100 bg-slate-50/80" : "border-white/10 bg-white/[0.04]",
)}
>
<div
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center text-xs font-black shrink-0",
isLight ? "bg-indigo-100 text-indigo-700" : "bg-indigo-500/20 text-indigo-200",
)}
>
{row.initials}
</div>
<div className="min-w-0 flex-1">
<p className={cn("text-sm font-bold truncate", isLight ? "text-slate-900" : "text-white")}>
{row.label}
</p>
<p className={cn("text-xs truncate", isLight ? "text-slate-500" : "text-slate-400")}>
{row.summary}
</p>
</div>
<div className="text-right shrink-0">
<p className={cn("text-sm font-black", isLight ? "text-emerald-700" : "text-emerald-400")}>
{row.amount != null
? formatMoney(row.amount, row.currency || data.attributedSupportSales.currency)
: "—"}
</p>
<span
className={cn(
"text-[10px] font-bold uppercase px-2 py-0.5 rounded-md inline-block mt-1",
"bg-emerald-500/15 text-emerald-500",
)}
>
AI session
</span>
</div>
</div>
))
)}
</div>
<div
className={cn(
"mt-4 pt-4 border-t flex justify-between items-center",
isLight ? "border-slate-100" : "border-white/10",
)}
>
<span className={cn("text-xs font-bold uppercase tracking-wide", isLight ? "text-slate-500" : "text-slate-400")}>
Total attributed (window)
</span>
<span className={cn("text-lg font-black", isLight ? "text-emerald-600" : "text-emerald-400")}>
{formatMoney(
data.attributedSupportSales.totalAmount,
data.attributedSupportSales.currency,
)}
</span>
</div>
</div>
</div>
</> </>
)} )}
</div> </div>

View File

@ -442,6 +442,32 @@ export default function StorefrontChatWidgetPage() {
) )
} }
const reportWidgetEvent = (message: WidgetMessage, action: Extract<WidgetAction, { type: "open_cart" }>) => {
if (!shop || !visitorId) return
const payload = JSON.stringify({
shop,
visitorId,
type: "cart_link_opened",
productId: action.product?.productId,
variantId: action.product?.variantId,
sourceMessageId: message.id,
})
try {
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/storefront-chat/events", new Blob([payload], { type: "application/json" }))
return
}
} catch {
/* ignore and fallback */
}
void fetch("/api/storefront-chat/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {})
}
const renderMessageActions = (message: WidgetMessage) => { const renderMessageActions = (message: WidgetMessage) => {
if (message.senderType !== "AI" || !message.actions?.length) return null if (message.senderType !== "AI" || !message.actions?.length) return null
@ -453,7 +479,10 @@ export default function StorefrontChatWidgetPage() {
<button <button
key={`${message.id}-${action.type}-${index}`} key={`${message.id}-${action.type}-${index}`}
type="button" type="button"
onClick={() => window.open(action.cartUrl, "_blank", "noopener,noreferrer")} onClick={() => {
reportWidgetEvent(message, action)
window.open(action.cartUrl, "_blank", "noopener,noreferrer")
}}
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-800 hover:bg-emerald-100" className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-800 hover:bg-emerald-100"
> >
{action.label} {action.label}
@ -469,10 +498,10 @@ export default function StorefrontChatWidgetPage() {
> >
<div className="flex gap-2"> <div className="flex gap-2">
{action.product.imageUrl ? ( {action.product.imageUrl ? (
<img <span
src={action.product.imageUrl} aria-hidden="true"
alt="" className="h-12 w-12 flex-shrink-0 rounded-lg bg-slate-100 bg-cover bg-center"
className="h-12 w-12 flex-shrink-0 rounded-lg object-cover" style={{ backgroundImage: `url(${action.product.imageUrl})` }}
/> />
) : null} ) : null}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@ -271,6 +271,56 @@ function extractLastCustomerContext(
return null return null
} }
function normalizeProductMatchText(input: string): string {
return input.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function significantProductTerms(input: string): Set<string> {
const ignored = new Set([
"want",
"buy",
"some",
"sort",
"kind",
"need",
"looking",
"please",
"product",
"item",
"with",
"that",
"this",
"have",
"show",
])
return new Set(
normalizeProductMatchText(input)
.split(" ")
.filter((term) => term.length >= 3 && !ignored.has(term)),
)
}
function chooseProductHitForActions(
hits: ProductSearchHit[],
replyText: string,
customerMessage: string,
): ProductSearchHit | null {
const reply = normalizeProductMatchText(replyText)
const mentioned = hits.find((hit) => {
const title = normalizeProductMatchText(hit.title)
return title.length > 0 && reply.includes(title)
})
if (mentioned) return mentioned
const queryTerms = significantProductTerms(customerMessage)
if (queryTerms.size === 0) return hits[0] ?? null
return (
hits.find((hit) => hit.tokens.some((token) => queryTerms.has(token))) ??
null
)
}
async function composeReply(params: { async function composeReply(params: {
storeName: string storeName: string
storeReference: string storeReference: string
@ -745,27 +795,6 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
} }
const actions: StorefrontChatAction[] = []
let pendingCartProduct: StorefrontActionProduct | null = null
if (input.channel === "widget" && productHits.length > 0) {
const primaryHit = productHits[0]
const primaryProduct = productSearchHitToActionProduct(primaryHit)
if (primaryProduct) {
pendingCartProduct = primaryProduct
actions.push(buildAddToCartAction(primaryProduct, "primary"))
const upsells = await findAutomaticUpsellCandidates({
storeId: input.storeId,
baseProduct: primaryProduct,
query: effectiveMessage,
limit: 1,
})
if (upsells[0]) {
usedTools.push("automatic_upsell")
actions.push(buildUpsellSuggestionAction(upsells[0], primaryProduct.title))
}
}
}
const facts = [ const facts = [
type1Facts, type1Facts,
kbContext ? `Knowledge:\n${kbContext}` : "", kbContext ? `Knowledge:\n${kbContext}` : "",
@ -782,6 +811,27 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
history: historyToGroq(input.history), history: historyToGroq(input.history),
}) })
const actions: StorefrontChatAction[] = []
let pendingCartProduct: StorefrontActionProduct | null = null
if (input.channel === "widget" && productHits.length > 0) {
const primaryHit = chooseProductHitForActions(productHits, text, effectiveMessage)
const primaryProduct = primaryHit ? productSearchHitToActionProduct(primaryHit) : null
if (primaryProduct) {
pendingCartProduct = primaryProduct
actions.push(buildAddToCartAction(primaryProduct, "primary"))
const upsells = await findAutomaticUpsellCandidates({
storeId: input.storeId,
baseProduct: primaryProduct,
query: effectiveMessage,
limit: 1,
})
if (upsells[0]) {
usedTools.push("automatic_upsell")
actions.push(buildUpsellSuggestionAction(upsells[0], primaryProduct.title))
}
}
}
logger.info("[L1_ORCHESTRATOR]", { logger.info("[L1_ORCHESTRATOR]", {
storeId: input.storeId, storeId: input.storeId,
channel: input.channel, channel: input.channel,

View File

@ -73,11 +73,33 @@ function imageUrlFromImages(images: unknown): string | null {
} }
function termsFromText(text: string) { function termsFromText(text: string) {
const ignored = new Set([
"want",
"wants",
"buy",
"some",
"sort",
"kind",
"need",
"needs",
"looking",
"please",
"product",
"products",
"item",
"items",
"show",
"have",
"with",
"that",
"this",
"your",
])
return text return text
.toLowerCase() .toLowerCase()
.split(/[^a-z0-9]+/g) .split(/[^a-z0-9]+/g)
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t.length >= 2) .filter((t) => t.length >= 2 && !ignored.has(t))
} }
export function productSearchHitToActionProduct(hit: ProductSearchHit): StorefrontActionProduct | null { export function productSearchHitToActionProduct(hit: ProductSearchHit): StorefrontActionProduct | null {
@ -125,14 +147,22 @@ export async function searchProductsForStore(
const scored: { p: (typeof products)[0]; score: number }[] = [] const scored: { p: (typeof products)[0]; score: number }[] = []
for (const p of products) { for (const p of products) {
const hay = `${p.title} ${p.productType ?? ""} ${JSON.stringify(p.variants)} ${metafieldsToSummary(p.metafields) ?? ""}`.toLowerCase() const title = p.title.toLowerCase()
const productType = (p.productType ?? "").toLowerCase()
const variantsText = JSON.stringify(p.variants).toLowerCase()
const metafieldsText = (metafieldsToSummary(p.metafields) ?? "").toLowerCase()
const hay = `${title} ${productType} ${variantsText} ${metafieldsText}`
let score = 0 let score = 0
for (const t of terms) { for (const t of terms) {
if (title.includes(t)) score += 8
if (productType.includes(t)) score += 4
if (variantsText.includes(t)) score += 2
if (metafieldsText.includes(t)) score += 1
if (hay.includes(t)) score += 1 if (hay.includes(t)) score += 1
} }
if (score > 0) scored.push({ p, score }) if (score > 0) scored.push({ p, score })
} }
scored.sort((a, b) => b.score - a.score) scored.sort((a, b) => b.score - a.score || a.p.title.localeCompare(b.p.title))
return scored.slice(0, limit).map(({ p }) => { return scored.slice(0, limit).map(({ p }) => {
const variants = extractCartableVariants(p.variants) const variants = extractCartableVariants(p.variants)

View File

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest"
import { classifyAiResolution, classifyCustomerSentiment } from "./aiEvents"
describe("AI analytics event classifiers", () => {
it("keeps low-confidence sentiment conservative", () => {
expect(classifyCustomerSentiment("Do you have waterproof boots?")).toBe("neutral")
})
it("detects negative and urgent customer messages", () => {
expect(classifyCustomerSentiment("My parcel is missing and I want a refund")).toBe("negative")
expect(classifyCustomerSentiment("This is urgent and I need help immediately")).toBe("urgent")
})
it("classifies AI escalation and unresolved replies", () => {
expect(classifyAiResolution({ content: "A human will follow up", module: "HUMAN_ESCALATION" })).toBe(
"escalated",
)
expect(classifyAiResolution({ content: "I could not find that information right now." })).toBe(
"needs_follow_up",
)
expect(classifyAiResolution({ content: "Yes, this product is available in black." })).toBe(
"resolved_by_ai",
)
})
})

View File

@ -0,0 +1,114 @@
import type { Prisma } from "@prisma/client"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
export type AiAnalyticsEventType =
| "customer_question"
| "ai_answered"
| "add_to_cart_clicked"
| "cart_link_generated"
| "cart_link_opened"
| "upsell_shown"
| "upsell_clicked"
| "cross_sell_clicked"
| "ticket_created"
| "unresolved_question"
| "resolved_by_ai"
| "order_attributed"
export type AiAnalyticsSentiment = "positive" | "neutral" | "negative" | "urgent" | "unknown"
export type AiResolutionStatus = "resolved_by_ai" | "needs_follow_up" | "escalated" | "unknown"
export type LogAiAnalyticsEventInput = {
storeId: string
conversationId?: string | null
messageId?: string | null
eventType: AiAnalyticsEventType
source?: string
productId?: string | null
variantId?: string | null
quantity?: number | null
amount?: number | null
currency?: string | null
sentiment?: AiAnalyticsSentiment | null
resolutionStatus?: AiResolutionStatus | null
orderId?: string | null
attributionType?: "direct_upsell" | "direct_cross_sell" | "assisted_order" | null
metadata?: unknown
createdAt?: Date
}
function toJsonInput(value: unknown): Prisma.InputJsonValue | undefined {
if (value === undefined) return undefined
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
}
export function classifyCustomerSentiment(text: string): AiAnalyticsSentiment {
const t = text.trim().toLowerCase()
if (!t) return "unknown"
if (/\b(angry|furious|terrible|awful|scam|fraud|lawsuit|chargeback|urgent|asap|immediately)\b/.test(t)) {
return "urgent"
}
if (/\b(bad|poor|wrong|broken|damaged|late|missing|not received|disappointed|unhappy|refund|complaint)\b/.test(t)) {
return "negative"
}
if (/\b(thanks|thank you|great|love|good|perfect|helpful|awesome|excellent)\b/.test(t)) {
return "positive"
}
return "neutral"
}
export function classifyAiResolution(params: {
content: string
usedTools?: string[]
module?: string
}): AiResolutionStatus {
const text = params.content.trim().toLowerCase()
const tools = params.usedTools ?? []
if (tools.includes("ticket_intake") || params.module === "HUMAN_ESCALATION") {
return "escalated"
}
if (
/\b(i don'?t know|do not have|don't have|could not find|cannot look up|can't look up|not available|human support|team can confirm|verify your email)\b/.test(
text,
)
) {
return "needs_follow_up"
}
return text ? "resolved_by_ai" : "unknown"
}
export async function logAiAnalyticsEvent(input: LogAiAnalyticsEventInput): Promise<void> {
try {
await prisma.aiAnalyticsEvent.create({
data: {
storeId: input.storeId,
conversationId: input.conversationId ?? null,
messageId: input.messageId ?? null,
eventType: input.eventType,
source: input.source ?? "storefront_chat",
productId: input.productId ?? null,
variantId: input.variantId ?? null,
quantity: input.quantity ?? null,
amount: input.amount ?? null,
currency: input.currency ?? null,
sentiment: input.sentiment ?? null,
resolutionStatus: input.resolutionStatus ?? null,
orderId: input.orderId ?? null,
attributionType: input.attributionType ?? null,
metadata: toJsonInput(input.metadata),
...(input.createdAt ? { createdAt: input.createdAt } : {}),
},
})
} catch (error) {
logger.warn("logAiAnalyticsEvent failed", {
eventType: input.eventType,
storeId: input.storeId,
message: error instanceof Error ? error.message : String(error),
})
}
}
export async function logAiAnalyticsEvents(inputs: LogAiAnalyticsEventInput[]): Promise<void> {
await Promise.all(inputs.map((input) => logAiAnalyticsEvent(input)))
}

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest"
import { computeAiRevenueAttribution, parseOrderLineItems } from "./revenueAttribution"
describe("revenueAttribution", () => {
it("parses Shopify line item identifiers conservatively", () => {
expect(
parseOrderLineItems([
{ product_id: 123, variant_id: 456, title: "Socks", quantity: 2, price: "4.50" },
]),
).toEqual([
{ productId: "123", variantId: "456", title: "Socks", quantity: 2, price: 4.5 },
])
})
it("attributes direct upsell revenue only when the clicked product is in a matching order", () => {
const result = computeAiRevenueAttribution({
events: [
{
id: "event-1",
conversationId: "conv-1",
eventType: "upsell_clicked",
productId: "p-upsell",
variantId: "v-upsell",
quantity: 1,
createdAt: new Date("2026-05-01T10:00:00Z"),
},
{
id: "event-2",
conversationId: "conv-1",
eventType: "customer_question",
productId: null,
variantId: null,
quantity: null,
createdAt: new Date("2026-05-01T09:59:00Z"),
},
],
conversations: [
{
id: "conv-1",
customerEmail: "buyer@example.com",
boundShopifyOrderId: null,
createdAt: new Date("2026-05-01T09:58:00Z"),
updatedAt: new Date("2026-05-01T10:02:00Z"),
},
],
orders: [
{
shopifyOrderId: "order-1",
orderNumber: "#1001",
email: "buyer@example.com",
totalPrice: "50.00",
currency: "GBP",
shopifyCreatedAt: new Date("2026-05-01T10:10:00Z"),
createdAt: new Date("2026-05-01T10:10:00Z"),
lineItems: [
{ product_id: "p-base", variant_id: "v-base", title: "Shoes", quantity: 1, price: "40.00" },
{ product_id: "p-upsell", variant_id: "v-upsell", title: "Socks", quantity: 2, price: "5.00" },
],
},
],
})
expect(result.directUpsellRevenue).toBe(10)
expect(result.aiAssistedRevenue).toBe(50)
expect(result.directRows).toHaveLength(1)
expect(result.assistedRows).toHaveLength(1)
})
it("does not inflate direct revenue without exact product or variant matching", () => {
const result = computeAiRevenueAttribution({
events: [
{
id: "event-1",
conversationId: "conv-1",
eventType: "upsell_clicked",
productId: "p-clicked",
variantId: "v-clicked",
quantity: 1,
createdAt: new Date("2026-05-01T10:00:00Z"),
},
],
conversations: [
{
id: "conv-1",
customerEmail: "buyer@example.com",
boundShopifyOrderId: null,
createdAt: new Date("2026-05-01T09:58:00Z"),
updatedAt: new Date("2026-05-01T10:02:00Z"),
},
],
orders: [
{
shopifyOrderId: "order-1",
orderNumber: "#1001",
email: "buyer@example.com",
totalPrice: "50.00",
currency: "GBP",
shopifyCreatedAt: new Date("2026-05-01T10:10:00Z"),
createdAt: new Date("2026-05-01T10:10:00Z"),
lineItems: [{ product_id: "p-other", variant_id: "v-other", title: "Shoes", quantity: 1, price: "50.00" }],
},
],
})
expect(result.directUpsellRevenue).toBe(0)
expect(result.directRows).toHaveLength(0)
})
})

View File

@ -0,0 +1,195 @@
type EventLike = {
id: string
conversationId: string | null
eventType: string
productId: string | null
variantId: string | null
quantity: number | null
createdAt: Date
}
type ConversationLike = {
id: string
customerEmail: string | null
boundShopifyOrderId: string | null
createdAt: Date
updatedAt: Date
}
type OrderLike = {
shopifyOrderId: string
orderNumber: string
email: string | null
totalPrice: string
currency: string | null
lineItems: unknown
shopifyCreatedAt: Date | null
createdAt: Date
}
type LineItemLike = {
product_id?: unknown
productId?: unknown
variant_id?: unknown
variantId?: unknown
title?: unknown
name?: unknown
quantity?: unknown
price?: unknown
}
export type DirectRevenueRow = {
eventId: string
orderId: string
orderNumber: string
productId: string | null
variantId: string | null
title: string
amount: number
quantity: number
currency: string
attributionType: "direct_upsell" | "direct_cross_sell"
createdAt: Date
}
export type AssistedRevenueRow = {
orderId: string
orderNumber: string
amount: number
currency: string
conversationId: string
createdAt: Date
}
function asString(value: unknown): string | null {
if (value === null || value === undefined) return null
const text = String(value).trim()
return text ? text : null
}
function asNumber(value: unknown): number {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
export function parseOrderLineItems(raw: unknown): Array<{
productId: string | null
variantId: string | null
title: string
quantity: number
price: number
}> {
if (!Array.isArray(raw)) return []
return (raw as LineItemLike[]).map((line) => ({
productId: asString(line.product_id ?? line.productId),
variantId: asString(line.variant_id ?? line.variantId),
title: asString(line.title ?? line.name) ?? "Product",
quantity: Math.max(1, Math.trunc(asNumber(line.quantity) || 1)),
price: asNumber(line.price),
}))
}
function orderDate(order: OrderLike): Date {
return order.shopifyCreatedAt ?? order.createdAt
}
function orderMatchesConversation(order: OrderLike, conversation: ConversationLike) {
if (conversation.boundShopifyOrderId && conversation.boundShopifyOrderId === order.shopifyOrderId) {
return true
}
const email = order.email?.trim().toLowerCase()
const convEmail = conversation.customerEmail?.trim().toLowerCase()
return Boolean(email && convEmail && email === convEmail)
}
export function computeAiRevenueAttribution(params: {
events: EventLike[]
conversations: ConversationLike[]
orders: OrderLike[]
attributionDays?: number
}) {
const attributionMs = (params.attributionDays ?? 14) * 24 * 60 * 60 * 1000
const conversationById = new Map(params.conversations.map((c) => [c.id, c]))
const directRows: DirectRevenueRow[] = []
const directEvents = params.events.filter(
(event) => event.eventType === "upsell_clicked" || event.eventType === "cross_sell_clicked",
)
for (const event of directEvents) {
if (!event.conversationId) continue
const conversation = conversationById.get(event.conversationId)
if (!conversation) continue
const matchingOrders = params.orders.filter((order) => {
const createdAt = orderDate(order).getTime()
return (
createdAt >= event.createdAt.getTime() &&
createdAt <= event.createdAt.getTime() + attributionMs &&
orderMatchesConversation(order, conversation)
)
})
for (const order of matchingOrders) {
for (const line of parseOrderLineItems(order.lineItems)) {
const productMatch = event.productId && line.productId === event.productId
const variantMatch = event.variantId && line.variantId === event.variantId
if (!productMatch && !variantMatch) continue
directRows.push({
eventId: event.id,
orderId: order.shopifyOrderId,
orderNumber: order.orderNumber,
productId: line.productId,
variantId: line.variantId,
title: line.title,
amount: Math.round(line.price * line.quantity * 100) / 100,
quantity: line.quantity,
currency: order.currency ?? "GBP",
attributionType:
event.eventType === "cross_sell_clicked" ? "direct_cross_sell" : "direct_upsell",
createdAt: orderDate(order),
})
}
}
}
const aiConversationIds = new Set(
params.events
.filter((event) => event.eventType === "customer_question" || event.eventType === "ai_answered")
.map((event) => event.conversationId)
.filter((id): id is string => Boolean(id)),
)
const assistedRows: AssistedRevenueRow[] = []
const seenOrders = new Set<string>()
for (const conversation of params.conversations) {
if (!aiConversationIds.has(conversation.id)) continue
for (const order of params.orders) {
if (seenOrders.has(order.shopifyOrderId)) continue
if (!orderMatchesConversation(order, conversation)) continue
const amount = asNumber(order.totalPrice)
if (amount <= 0) continue
seenOrders.add(order.shopifyOrderId)
assistedRows.push({
orderId: order.shopifyOrderId,
orderNumber: order.orderNumber,
amount: Math.round(amount * 100) / 100,
currency: order.currency ?? "GBP",
conversationId: conversation.id,
createdAt: orderDate(order),
})
}
}
return {
directRows,
assistedRows,
directUpsellRevenue: directRows
.filter((row) => row.attributionType === "direct_upsell")
.reduce((sum, row) => sum + row.amount, 0),
directCrossSellRevenue: directRows
.filter((row) => row.attributionType === "direct_cross_sell")
.reduce((sum, row) => sum + row.amount, 0),
aiAssistedRevenue: assistedRows.reduce((sum, row) => sum + row.amount, 0),
}
}

View File

@ -52,6 +52,15 @@ export const storefrontChatActionSchema = z.object({
sourceMessageId: z.string().trim().max(128).optional(), sourceMessageId: z.string().trim().max(128).optional(),
}) })
export const storefrontChatEventSchema = z.object({
shop: storefrontShopSchema,
visitorId: storefrontVisitorIdSchema,
type: z.enum(["cart_link_opened"]),
productId: z.string().trim().min(1).max(128).optional(),
variantId: z.string().trim().min(1).max(128).optional(),
sourceMessageId: z.string().trim().max(128).optional(),
})
/** Anonymous storefront widget message */ /** Anonymous storefront widget message */
export const storefrontChatPostSchema = z export const storefrontChatPostSchema = z
.object({ .object({
@ -149,6 +158,7 @@ export const storefrontOtpVerifySchema = z.object({
export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema> export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema>
export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema> export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema>
export type StorefrontChatActionInput = z.infer<typeof storefrontChatActionSchema> export type StorefrontChatActionInput = z.infer<typeof storefrontChatActionSchema>
export type StorefrontChatEventInput = z.infer<typeof storefrontChatEventSchema>
export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema> export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema>
export type StorefrontOtpRequestInput = z.infer<typeof storefrontOtpRequestSchema> export type StorefrontOtpRequestInput = z.infer<typeof storefrontOtpRequestSchema>
export type StorefrontOtpVerifyInput = z.infer<typeof storefrontOtpVerifySchema> export type StorefrontOtpVerifyInput = z.infer<typeof storefrontOtpVerifySchema>

View File

@ -3,6 +3,7 @@ import {
newsletterSubscribeSchema, newsletterSubscribeSchema,
storefrontChatPostSchema, storefrontChatPostSchema,
storefrontChatActionSchema, storefrontChatActionSchema,
storefrontChatEventSchema,
chatMessagePostSchema, chatMessagePostSchema,
CHAT_MESSAGE_CONTENT_MAX, CHAT_MESSAGE_CONTENT_MAX,
CHAT_ATTACHMENT_JSON_MAX, CHAT_ATTACHMENT_JSON_MAX,
@ -75,6 +76,29 @@ describe("storefrontChatActionSchema", () => {
}) })
}) })
describe("storefrontChatEventSchema", () => {
it("accepts cart-link-opened events", () => {
const result = storefrontChatEventSchema.safeParse({
shop: "x.myshopify.com",
visitorId: "v1",
type: "cart_link_opened",
productId: "p1",
variantId: "v1",
})
expect(result.success).toBe(true)
})
it("rejects unknown storefront analytics events", () => {
expect(
storefrontChatEventSchema.safeParse({
shop: "x.myshopify.com",
visitorId: "v1",
type: "checkout_started",
}).success,
).toBe(false)
})
})
describe("chatMessagePostSchema", () => { describe("chatMessagePostSchema", () => {
it("rejects oversize content", () => { it("rejects oversize content", () => {
expect( expect(

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest"
import { bucketKey, emptyTimeline, getAnalyticsWindow } from "./analyticsTimeline"
describe("analyticsTimeline", () => {
it("defaults 30 day ranges to day grain", () => {
const window = getAnalyticsWindow({
range: "30d",
now: new Date("2026-05-11T12:00:00Z"),
})
expect(window.grain).toBe("day")
expect(window.labels).toHaveLength(30)
})
it("buckets weeks from Monday", () => {
expect(bucketKey(new Date("2026-05-11T12:00:00Z"), "week")).toBe("2026-05-11")
expect(bucketKey(new Date("2026-05-17T12:00:00Z"), "week")).toBe("2026-05-11")
})
it("creates zero-filled rows for every label", () => {
const rows = emptyTimeline(["2026-05-10", "2026-05-11"], "day", { questions: 0, revenue: 0 })
expect(rows).toEqual([
{ key: "2026-05-10", label: "May 10", questions: 0, revenue: 0 },
{ key: "2026-05-11", label: "May 11", questions: 0, revenue: 0 },
])
})
})

View File

@ -0,0 +1,118 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
format,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subMonths,
subYears,
} from "date-fns"
export type AnalyticsRange = "today" | "7d" | "30d" | "90d" | "12m" | "year" | "all"
export type AnalyticsGrain = "day" | "week" | "month" | "year"
export type AnalyticsWindow = {
range: AnalyticsRange
grain: AnalyticsGrain
since: Date
until: Date
labels: string[]
}
export function parseAnalyticsRange(raw: string | null | undefined): AnalyticsRange {
if (raw === "today" || raw === "7d" || raw === "30d" || raw === "90d" || raw === "12m" || raw === "year" || raw === "all") {
return raw
}
return "30d"
}
export function defaultGrainForRange(range: AnalyticsRange): AnalyticsGrain {
if (range === "today" || range === "7d" || range === "30d") return "day"
if (range === "90d") return "week"
return "month"
}
export function parseAnalyticsGrain(
raw: string | null | undefined,
range: AnalyticsRange,
): AnalyticsGrain {
if (raw === "day" || raw === "week" || raw === "month" || raw === "year") return raw
return defaultGrainForRange(range)
}
export function startOfBucket(date: Date, grain: AnalyticsGrain): Date {
if (grain === "day") return startOfDay(date)
if (grain === "week") return startOfWeek(date, { weekStartsOn: 1 })
if (grain === "year") return startOfYear(date)
return startOfMonth(date)
}
export function addBucket(date: Date, grain: AnalyticsGrain): Date {
if (grain === "day") return addDays(date, 1)
if (grain === "week") return addWeeks(date, 1)
if (grain === "year") return addYears(date, 1)
return addMonths(date, 1)
}
export function bucketLabel(date: Date, grain: AnalyticsGrain): string {
if (grain === "day") return format(date, "MMM d")
if (grain === "week") return `Week of ${format(date, "MMM d")}`
if (grain === "year") return format(date, "yyyy")
return format(date, "MMM yyyy")
}
export function bucketKey(date: Date, grain: AnalyticsGrain): string {
return format(startOfBucket(date, grain), "yyyy-MM-dd")
}
export function getAnalyticsWindow(params: {
range?: string | null
grain?: string | null
now?: Date
} = {}): AnalyticsWindow {
const now = params.now ?? new Date()
const range = parseAnalyticsRange(params.range)
const grain = parseAnalyticsGrain(params.grain, range)
const until = now
const since =
range === "today"
? startOfDay(now)
: range === "7d"
? subDays(startOfDay(now), 6)
: range === "30d"
? subDays(startOfDay(now), 29)
: range === "90d"
? subDays(startOfDay(now), 89)
: range === "12m"
? subMonths(startOfMonth(now), 11)
: range === "year"
? startOfYear(now)
: subYears(startOfYear(now), 4)
const labels: string[] = []
let cursor = startOfBucket(since, grain)
const final = startOfBucket(until, grain)
while (cursor.getTime() <= final.getTime() && labels.length < 400) {
labels.push(bucketKey(cursor, grain))
cursor = addBucket(cursor, grain)
}
return { range, grain, since, until, labels }
}
export function emptyTimeline<T extends Record<string, number>>(
labels: string[],
grain: AnalyticsGrain,
seed: T,
) {
return labels.map((key) => ({
key,
label: bucketLabel(new Date(`${key}T00:00:00`), grain),
...seed,
}))
}