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");

View File

@ -109,7 +109,9 @@ model Store {
// Customer-facing storefront widget (floating chat) enablement. // Customer-facing storefront widget (floating chat) enablement.
storefrontChatEnabled Boolean @default(false) storefrontChatEnabled Boolean @default(false)
/** After OTP verify, widget session length (minutes). Clamped server-side (e.g. 5–10080). Default 60. */ /**
* After OTP verify, widget session length (minutes). Clamped server-side (e.g. 5–10080). Default 60.
*/
storefrontOtpSessionMinutes Int @default(60) storefrontOtpSessionMinutes Int @default(60)
storefrontChatName String? storefrontChatName String?
storefrontChatTagline String? storefrontChatTagline String?
@ -117,21 +119,35 @@ model Store {
storefrontChatFontFamily String? storefrontChatFontFamily String?
storefrontChatLogoUrl String? storefrontChatLogoUrl String?
/** Primary knowledge source: structured questionnaire vs uploaded documents (mutually exclusive). */ /**
* Primary knowledge source: structured questionnaire vs uploaded documents (mutually exclusive).
*/
knowledgeBaseMode KnowledgeBaseMode @default(UNSET) knowledgeBaseMode KnowledgeBaseMode @default(UNSET)
/** Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. */ /**
* Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks.
*/
knowledgeTemplate Json? knowledgeTemplate Json?
/** When true, L1 may use DB-backed category/product metafield tools for customer answers. */ /**
* When true, L1 may use DB-backed category/product metafield tools for customer answers.
*/
categoryMetadetailsEnabled Boolean @default(false) categoryMetadetailsEnabled Boolean @default(false)
/** Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. */ /**
* Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }.
*/
aiModules Json? aiModules Json?
/** FAQ-style policy text per L1 module for embeddings, e.g. { "ORDER_STATUS": "..." }. */ /**
* FAQ-style policy text per L1 module for embeddings, e.g. { "ORDER_STATUS": "..." }.
*/
modulePolicies Json? modulePolicies Json?
/** Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */ /**
* Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2).
*/
sharedFieldAnswers Json? sharedFieldAnswers Json?
/** Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. */ /**
* Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side.
*/
gmailReplyTheme Json? gmailReplyTheme Json?
/** /**
@ -163,6 +179,7 @@ model Store {
gmailInboundAutoReplies GmailInboundAutoReply[] gmailInboundAutoReplies GmailInboundAutoReply[]
templates Template[] templates Template[]
chatMessages ChatMessage[] chatMessages ChatMessage[]
aiAnalyticsEvents AiAnalyticsEvent[]
lowStockAlertStates LowStockAlertState[] lowStockAlertStates LowStockAlertState[]
lowStockShopifyVendorAlertStates LowStockShopifyVendorAlertState[] lowStockShopifyVendorAlertStates LowStockShopifyVendorAlertState[]
supplierAutomationLogs SupplierAutomationLog[] supplierAutomationLogs SupplierAutomationLog[]
@ -173,7 +190,9 @@ model Store {
@@index([userId]) // Add index for performance @@index([userId]) // Add index for performance
} }
/** Dedupes low-stock B2B notifications per store + product + supplier while supplier-reported qty stays below threshold. */ /**
* Dedupes low-stock B2B notifications per store + product + supplier while supplier-reported qty stays below threshold.
*/
model LowStockAlertState { model LowStockAlertState {
id String @id @default(cuid()) id String @id @default(cuid())
storeId String storeId String
@ -182,7 +201,9 @@ model LowStockAlertState {
supplierId String supplierId String
supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade) supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade)
lastSeenSupplierQty Int? lastSeenSupplierQty Int?
/** Set when we sent a low-stock alert; cleared when supplier qty is back >= threshold. */ /**
* Set when we sent a low-stock alert; cleared when supplier qty is back >= threshold.
*/
lastNotifiedBelowAt DateTime? lastNotifiedBelowAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -193,14 +214,18 @@ model LowStockAlertState {
@@index([supplierId]) @@index([supplierId])
} }
/** Dedupes Shopify low-stock vendor notifications per store + product while total stays below threshold. */ /**
* Dedupes Shopify low-stock vendor notifications per store + product while total stays below threshold.
*/
model LowStockShopifyVendorAlertState { model LowStockShopifyVendorAlertState {
id String @id @default(cuid()) id String @id @default(cuid())
storeId String storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
shopifyProductId String shopifyProductId String
lastSeenShopifyTotal Int? lastSeenShopifyTotal Int?
/** Set when we created a vendor notification; cleared when Shopify total is back >= threshold. */ /**
* Set when we created a vendor notification; cleared when Shopify total is back >= threshold.
*/
lastNotifiedBelowAt DateTime? lastNotifiedBelowAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -241,7 +266,9 @@ model CachedProduct {
status String? status String?
variants Json // Store variants as JSON variants Json // Store variants as JSON
images Json // Store images as JSON images Json // Store images as JSON
/** Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search. */ /**
* Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search.
*/
metafields Json? metafields Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -263,7 +290,9 @@ model CachedOrder {
currency String? currency String?
financialStatus String? financialStatus String?
fulfillmentStatus String? fulfillmentStatus String?
/** When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). */ /**
* When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES).
*/
holdReasonCode String? holdReasonCode String?
holdNote String? @db.Text holdNote String? @db.Text
trackingNumber String? trackingNumber String?
@ -299,7 +328,9 @@ model CachedCustomer {
totalSpent String? totalSpent String?
phone String? phone String?
tags String? tags String?
/** Shopify REST `default_address` snapshot for vendor UI (city, country, etc.). */ /**
* Shopify REST `default_address` snapshot for vendor UI (city, country, etc.).
*/
defaultAddress Json? defaultAddress Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -358,9 +389,9 @@ model Connection {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
supplierProductOffers SupplierProductOffer[]
@@unique([storeId, supplierId]) @@unique([storeId, supplierId])
supplierProductOffers SupplierProductOffer[]
} }
model Invitation { model Invitation {
@ -514,7 +545,9 @@ model Notification {
@@index([createdAt]) @@index([createdAt])
} }
/** One row per inbound Gmail message id that received an auto-reply (idempotency). */ /**
* One row per inbound Gmail message id that received an auto-reply (idempotency).
*/
model GmailInboundAutoReply { model GmailInboundAutoReply {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@ -687,7 +720,9 @@ model ChatMessage {
id String @id @default(cuid()) id String @id @default(cuid())
senderId String senderId String
receiverId String receiverId String
/** Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading. */ /**
* Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading.
*/
storeId String? storeId String?
kind ChatMessageKind @default(TEXT) kind ChatMessageKind @default(TEXT)
content String @db.Text content String @db.Text
@ -716,17 +751,27 @@ model StorefrontChatConversation {
visitorId String visitorId String
customerEmail String? customerEmail String?
/** Email the customer entered for OTP (asserted order email). */ /**
* Email the customer entered for OTP (asserted order email).
*/
assertedEmail String? assertedEmail String?
emailVerifiedAt DateTime? emailVerifiedAt DateTime?
/** Verified session TTL (e.g. 1h from verification). */ /**
* Verified session TTL (e.g. 1h from verification).
*/
sessionExpiresAt DateTime? sessionExpiresAt DateTime?
/** After order disambiguation, pinned Shopify order id for this session. */ /**
* After order disambiguation, pinned Shopify order id for this session.
*/
boundShopifyOrderId String? boundShopifyOrderId String?
/** SHA-256 hex of OTP + store-visitor salt; cleared after verify. */ /**
* SHA-256 hex of OTP + store-visitor salt; cleared after verify.
*/
otpHash String? otpHash String?
otpExpiresAt DateTime? otpExpiresAt DateTime?
/** L1 orchestrator: phase, module, slots (JSON). */ /**
* L1 orchestrator: phase, module, slots (JSON).
*/
l1State Json? l1State Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -751,6 +796,33 @@ model StorefrontChatMessage {
@@index([conversationId, createdAt]) @@index([conversationId, createdAt])
} }
model AiAnalyticsEvent {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
conversationId String?
messageId String?
eventType String
source String
productId String?
variantId String?
quantity Int?
amount Float?
currency String?
sentiment String?
resolutionStatus String?
orderId String?
attributionType String?
metadata Json?
createdAt DateTime @default(now())
@@index([storeId, eventType, createdAt])
@@index([storeId, createdAt])
@@index([conversationId])
@@index([productId])
@@index([orderId])
}
model PushSubscription { model PushSubscription {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

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,19 +123,26 @@ 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[],
] =
await Promise.all([
prisma.storefrontChatMessage.findMany({ prisma.storefrontChatMessage.findMany({
where: { where: {
conversation: { storeId: store.id }, conversation: { storeId: store.id },
createdAt: { gte: since }, createdAt: { gte: window.since, lte: window.until },
}, },
select: { select: {
senderType: true, senderType: true,
@ -63,43 +152,120 @@ export async function GET() {
}, },
orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }],
}), }),
prisma.gmailInboundAutoReply.findMany({ prismaWithAnalytics.aiAnalyticsEvent.findMany({
where: { storeId: store.id, createdAt: { gte: since } }, where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
select: { createdAt: true }, select: {
id: true,
conversationId: true,
messageId: true,
eventType: true,
source: true,
productId: true,
variantId: true,
quantity: true,
amount: true,
currency: true,
sentiment: true,
resolutionStatus: true,
orderId: true,
attributionType: true,
metadata: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
}), }),
prisma.storefrontChatConversation.findMany({ prisma.storefrontChatConversation.findMany({
where: { where: { storeId: store.id, updatedAt: { gte: window.since } },
storeId: store.id,
boundShopifyOrderId: { not: null },
updatedAt: { gte: subMonths(new Date(), 1) },
},
select: { select: {
id: true, id: true,
visitorId: true, visitorId: true,
customerEmail: true, customerEmail: true,
boundShopifyOrderId: true, boundShopifyOrderId: true,
createdAt: true,
updatedAt: true, updatedAt: true,
}, },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "asc" },
take: 25, }),
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({ prisma.chatMessage.findMany({
where: { storeId: store.id, createdAt: { gte: since } }, where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
select: { senderId: true, receiverId: true, createdAt: true }, select: { senderId: true, receiverId: true, createdAt: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
take: 8000, 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
aiLatencySeconds.push(sec)
const mk = format(startOfMonth(m.createdAt), "yyyy-MM")
if (mk in aiLatencyByMonth) aiLatencyByMonth[mk]!.push(sec)
pendingCustomer = null
}
}
const automationSeries = keys.map((key) => {
const { ai, cust } = aiShareByMonth[key] ?? { ai: 0, cust: 0 }
const vol = ai + cust
const storefrontAiSharePct = vol > 0 ? Math.round((ai / vol) * 1000) / 10 : 0
return {
monthKey: key,
month: monthLabel(key),
storefrontAiSharePct,
gmailAutoReplies: gmailByMonth[key] ?? 0,
volumes: { aiMessages: ai, customerMessages: cust },
}
}) })
}
pendingByConversation.set(message.conversationId, message.createdAt)
} else if (message.senderType === "AI") {
const pendingAt = pendingByConversation.get(message.conversationId)
if (!pendingAt) continue
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
if (sec >= 0 && sec <= 3600) aiLatencySeconds.push(sec)
pendingByConversation.delete(message.conversationId)
}
}
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: {
storeId: store.id,
shopifyOrderId: { in: orderIds },
},
select: {
shopifyOrderId: true,
totalPrice: true,
currency: true,
orderNumber: true,
},
})
: []
const orderByShopifyId = new Map(cachedOrders.map((o) => [o.shopifyOrderId, o]))
const attributedRows = attributedConversations
.map((conv) => {
const oid = conv.boundShopifyOrderId
if (!oid) return null
const o = orderByShopifyId.get(oid)
const amt = o ? parseFloat(o.totalPrice || "0") : null
const currency = o?.currency ?? "GBP"
const hint =
conv.customerEmail?.split("@")[0]?.slice(0, 2).toUpperCase() ??
conv.visitorId.slice(0, 2).toUpperCase()
return {
id: conv.id,
initials: hint.padEnd(2, "?"),
label:
conv.customerEmail?.replace(/(@.).+$/, "$1***") ??
`Visitor ${conv.visitorId.slice(-6)}`,
summary: o?.orderNumber ? `Order ${o.orderNumber}` : "Linked storefront session → order",
amount: amt,
currency,
channel: "AI" as const,
updatedAt: conv.updatedAt.toISOString(),
} }
}) const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
.filter(Boolean) const aiMedianSeconds = median(aiLatencySeconds)
const manualMedianMinutes = median(manualLatencyMinutes)
const attributedTotal = attributedRows.reduce((sum, r) => sum + (r?.amount ?? 0), 0) const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60)
const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
const signalEvents = storefrontMessages.length + gmailAutoReplies.length const topProductQuestions = Array.from(productQuestionCounts.values())
const modelEvaluation = buildModelEvaluationSnapshot(store.id, { .sort((a, b) => b.count - a.count)
signalEvents, .slice(0, 8)
monthLabels: automationSeries.map((r) => r.month), const convertedProductIds = new Set(revenueAttribution.directRows.map((row) => row.productId).filter(Boolean))
}) const highInterestProducts = topProductQuestions
.filter((row) => !convertedProductIds.has(row.name))
.slice(0, 5)
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) => (
<button
key={option.value}
type="button"
onClick={() => setRange(option.value)}
className={cn( className={cn(
"text-xs font-semibold rounded-xl px-4 py-2 border", "rounded-xl border px-3 py-2 text-xs font-black transition",
isLight ? "bg-white border-slate-200 text-slate-600" : "bg-white/5 border-white/10 text-slate-300", range === option.value
? isLight
? "bg-indigo-600 border-indigo-600 text-white"
: "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",
)} )}
> >
<span className="opacity-70">Last 6 months · AI msgs </span> {option.label}
<span className="text-emerald-500">{data.totals.storefrontAiMessages}</span> </button>
<span className="opacity-70"> · Gmail auto </span> ))}
<span className="text-sky-400">{data.totals.gmailAutoReplies}</span>
</div> </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}
/>
<MetricCard
title="AI-assisted revenue"
value={formatMoney(data.revenueImpact.aiAssistedRevenue, data.revenueImpact.currency)}
helper="Full order revenue after prior AI engagement in the same customer/order window."
accent={isLight ? "text-indigo-700" : "text-indigo-300"}
isLight={isLight}
/>
<MetricCard
title="Unresolved questions"
value={data.customerExperience.unresolvedQuestions.toLocaleString()}
helper={`${formatPercent(data.customerExperience.unresolvedQuestionRate)} unresolved rate across customer questions.`}
accent={isLight ? "text-orange-700" : "text-orange-300"}
isLight={isLight}
/>
<MetricCard
title="Hours saved estimate"
value={`${data.automationOutcomes.hoursSavedEstimate.toLocaleString()}h`}
helper="Estimated from AI answer volume and median manual response time where samples exist."
accent={isLight ? "text-sky-700" : "text-sky-300"}
isLight={isLight}
/>
</section>
<section className="grid grid-cols-1 xl:grid-cols-3 gap-4 md:gap-6">
<div className={cn("xl:col-span-2 rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5">
<div> <div>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}> <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2>
Model performance <p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>{data.revenueImpact.methodology}</p>
</h3>
<p className={cn("text-xs mt-1 max-w-xl", isLight ? "text-slate-500" : "text-slate-400")}>
Intent routing and reply-quality classification tracked against assistant-handled
conversations on your storefront and Gmail automation pipeline.
</p>
</div> </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")}>
className={cn( {data.grain} view
"text-[11px] font-semibold space-y-1 rounded-xl px-4 py-3 border shrink-0",
isLight ? "bg-slate-50 border-slate-100 text-slate-600" : "bg-white/[0.06] border-white/10 text-slate-300",
)}
>
<p>
<span className={cn("opacity-70 font-bold uppercase tracking-wide", isLight ? "" : "")}>Model </span>
<span className={isLight ? "text-slate-900" : "text-white"}>{data.modelEvaluation.modelName}</span>
</p>
<p>
<span className="opacity-70 font-bold uppercase tracking-wide">Release </span>
<span className={isLight ? "text-slate-900" : "text-white"}>{data.modelEvaluation.pipelineVersion}</span>
</p>
<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> </span>
</p>
</div> </div>
</div> {timeline.length === 0 ? (
<EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-6"> ) : (
{( <div className="h-[300px] w-full min-w-0">
[
{ label: "Accuracy", value: data.modelEvaluation.accuracyPct, accent: "text-emerald-500" },
{ label: "Precision", value: data.modelEvaluation.precisionPct, accent: "text-sky-500" },
{ label: "Recall", value: data.modelEvaluation.recallPct, accent: "text-violet-500" },
{ label: "F1 score", value: data.modelEvaluation.f1Pct, accent: "text-indigo-500" },
] as const
).map((m) => (
<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>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="min-h-[220px]">
<h4 className={cn("text-sm font-bold mb-3", isLight ? "text-slate-800" : "text-slate-200")}>
F1 score trend
</h4>
<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
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>
</div>
</div>
<div>
<h4 className={cn("text-sm font-bold mb-3", isLight ? "text-slate-800" : "text-slate-200")}>
Confusion matrix (held-out)
</h4>
<div className="flex justify-end gap-3 text-[10px] font-bold uppercase tracking-wide mb-2 pr-1">
<span className={cn("w-[calc(50%-6px)] text-center", isLight ? "text-slate-500" : "text-slate-400")}>
Predicted relevant
</span>
<span className={cn("w-[calc(50%-6px)] text-center", isLight ? "text-slate-500" : "text-slate-400")}>
Predicted not relevant
</span>
</div>
<div className="flex gap-3">
<div
className={cn(
"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",
)}
>
<span>Actual relevant</span>
<span>Actual not relevant</span>
</div>
<div className="flex-1 grid grid-cols-2 gap-3">
<div
className={cn(
"rounded-xl border p-4 text-center",
isLight ? "bg-emerald-50 border-emerald-100 text-emerald-900" : "bg-emerald-500/10 border-emerald-500/25 text-emerald-100",
)}
>
<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>
<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 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> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)}
</div> </div>
<div <div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
className={cn( <h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2>
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px]", <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", Conservative sentiment from storefront customer messages. Unknown is kept out of the chart.
)}
>
<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")}>
Classified from customer messages via keyword rules (WISMO, returns, products, shipping).
</p> </p>
{sentimentBars.every((row) => row.count === 0) ? (
<EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState>
) : (
<div className="h-[240px] w-full min-w-0"> <div className="h-[240px] w-full min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart layout="vertical" data={barData} margin={{ left: 4, right: 16, top: 4, bottom: 4 }}> <BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<XAxis type="number" tick={{ fill: chartMuted, fontSize: 10 }} axisLine={false} tickLine={false} /> <XAxis dataKey="name" tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} dy={8} />
<YAxis <YAxis tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
type="category" <Tooltip {...tt} />
dataKey="name" <Bar dataKey="count" radius={[6, 6, 0, 0]}>
width={88} {sentimentBars.map((row) => (
tick={{ fill: chartMuted, fontSize: 11, fontWeight: 600 }} <Cell key={row.name} fill={row.fill} />
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> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)}
</div>
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2>
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed.
</p>
<div className="grid grid-cols-2 gap-3">
<MetricCard title="Questions handled" value={data.automationOutcomes.questionsAnswered.toLocaleString()} helper="Customer questions in the selected window." accent={isLight ? "text-slate-900" : "text-white"} isLight={isLight} />
<MetricCard title="Resolved by AI" value={formatPercent(data.automationOutcomes.resolutionRate)} helper={`${data.automationOutcomes.resolvedByAi.toLocaleString()} resolved events.`} accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
<MetricCard title="Tickets created" value={data.automationOutcomes.ticketsCreated.toLocaleString()} helper="Escalations sent to human support." accent={isLight ? "text-rose-700" : "text-rose-300"} isLight={isLight} />
<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> </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")}>Questions And Resolutions</h2>
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[320px]", <p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl", Timeline of demand, AI resolution, unresolved questions, and support tickets.
)}
>
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>
Response time: AI vs manual
</h3>
<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> </p>
<div className="h-[240px] w-full min-w-0"> <div className="h-[260px] w-full min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={responseBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}> <LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<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} />
axisLine={false} <Legend wrapperStyle={{ fontSize: 11, fontWeight: 700, color: chartMuted }} />
tickLine={false} <Line type="monotone" dataKey="questions" name="Questions" stroke="#6366f1" strokeWidth={2} dot={false} />
dy={8} <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} />
<YAxis <Line type="monotone" dataKey="ticketsCreated" name="Tickets" stroke="#ef4444" strokeWidth={2} dot={false} />
tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} </LineChart>
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> </ResponsiveContainer>
</div> </div>
<p className={cn("text-[10px] mt-2 font-medium", isLight ? "text-slate-400" : "text-slate-500")}> </div>
Samples: AI pairs {data.responseTime.sampleAiPairs} · Manual pairs{" "} </section>
{data.responseTime.sampleManualPairs}. Overall medians: AI{" "}
{(data.responseTime.aiMedianSeconds / 60).toFixed(1)} min · Manual{" "} <section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
{data.responseTime.manualMedianMinutes.toFixed(1)} min <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> </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> </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] 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")}> <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 Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.
orders for your active store.
</p> </p>
<div className="flex-1 overflow-y-auto space-y-2 min-h-[180px] max-h-[260px] pr-1 custom-scrollbar"> <div className="grid grid-cols-2 gap-3">
{data.attributedSupportSales.rows.length === 0 ? ( <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} />
<p className={cn("text-sm italic py-8 text-center", isLight ? "text-slate-400" : "text-slate-500")}> <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} />
No bound orders yet — when customers verify and attach an order in chat, <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} />
conversions appear here. <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} />
</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>
</section>
<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")}>
<summary className={cn("cursor-pointer text-sm font-black", isLight ? "text-slate-800" : "text-slate-200")}>
Technical Diagnostics
</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>
<p>Customer messages: {data.technicalDiagnostics.storefrontCustomerMessages}</p>
<p>Analytics events: {data.technicalDiagnostics.analyticsEvents}</p>
<p>Cached orders: {data.technicalDiagnostics.cachedOrders}</p>
</div> </div>
</details>
</> </>
)} )}
</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,
}))
}