revamp: ai analytics
This commit is contained in:
parent
a1646f3af9
commit
c5ed06697e
@ -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");
|
||||||
@ -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
|
||||||
|
|||||||
112
src/app/api/dashboard/ai-analytics/route.test.ts
Normal file
112
src/app/api/dashboard/ai-analytics/route.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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) {
|
||||||
|
|||||||
61
src/app/api/storefront-chat/events/route.ts
Normal file
61
src/app/api/storefront-chat/events/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
25
src/lib/analytics/aiEvents.test.ts
Normal file
25
src/lib/analytics/aiEvents.test.ts
Normal 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",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
114
src/lib/analytics/aiEvents.ts
Normal file
114
src/lib/analytics/aiEvents.ts
Normal 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)))
|
||||||
|
}
|
||||||
108
src/lib/analytics/revenueAttribution.test.ts
Normal file
108
src/lib/analytics/revenueAttribution.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
195
src/lib/analytics/revenueAttribution.ts
Normal file
195
src/lib/analytics/revenueAttribution.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
26
src/lib/dashboard/analyticsTimeline.test.ts
Normal file
26
src/lib/dashboard/analyticsTimeline.test.ts
Normal 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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
118
src/lib/dashboard/analyticsTimeline.ts
Normal file
118
src/lib/dashboard/analyticsTimeline.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user