From c5ed06697e33cdd719227288125f92ab102f0dd6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 12 May 2026 00:12:10 +0500 Subject: [PATCH] revamp: ai analytics --- .../migration.sql | 31 + prisma/schema.prisma | 790 ++++++++++-------- .../api/dashboard/ai-analytics/route.test.ts | 112 +++ src/app/api/dashboard/ai-analytics/route.ts | 543 ++++++++---- src/app/api/storefront-chat/events/route.ts | 61 ++ src/app/api/storefront-chat/messages/route.ts | 157 ++++ src/app/dashboard/ai-analytics/page.tsx | 787 +++++++---------- src/app/storefront-chat/widget/page.tsx | 39 +- src/lib/ai/orchestrator/runL1.ts | 92 +- src/lib/ai/tools/productTools.ts | 36 +- src/lib/analytics/aiEvents.test.ts | 25 + src/lib/analytics/aiEvents.ts | 114 +++ src/lib/analytics/revenueAttribution.test.ts | 108 +++ src/lib/analytics/revenueAttribution.ts | 195 +++++ src/lib/api-schemas/public.ts | 10 + src/lib/api-schemas/schemas.test.ts | 24 + src/lib/dashboard/analyticsTimeline.test.ts | 26 + src/lib/dashboard/analyticsTimeline.ts | 118 +++ 18 files changed, 2218 insertions(+), 1050 deletions(-) create mode 100644 prisma/migrations/20260511235500_ai_analytics_events/migration.sql create mode 100644 src/app/api/dashboard/ai-analytics/route.test.ts create mode 100644 src/app/api/storefront-chat/events/route.ts create mode 100644 src/lib/analytics/aiEvents.test.ts create mode 100644 src/lib/analytics/aiEvents.ts create mode 100644 src/lib/analytics/revenueAttribution.test.ts create mode 100644 src/lib/analytics/revenueAttribution.ts create mode 100644 src/lib/dashboard/analyticsTimeline.test.ts create mode 100644 src/lib/dashboard/analyticsTimeline.ts diff --git a/prisma/migrations/20260511235500_ai_analytics_events/migration.sql b/prisma/migrations/20260511235500_ai_analytics_events/migration.sql new file mode 100644 index 0000000..89f014e --- /dev/null +++ b/prisma/migrations/20260511235500_ai_analytics_events/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9949e5..731356d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,28 +26,28 @@ model User { image String? password String? role UserRole @default(VENDOR) - - activeStoreId String? // Track which store is currently active - + + activeStoreId String? // Track which store is currently active + // MFA (Multi-Factor Authentication) - mfaEnabled Boolean @default(false) - mfaSecret String? // Encrypted TOTP secret - + mfaEnabled Boolean @default(false) + mfaSecret String? // Encrypted TOTP secret + // Relations - sentMessages ChatMessage[] @relation("SentMessages") - receivedMessages ChatMessage[] @relation("ReceivedMessages") - accounts Account[] - sessions Session[] - stores Store[] // Changed from singular to support multiple stores - supplierProfile SupplierProfile? - notifications Notification[] - gmailIntegration GmailIntegration? - emailLogs EmailLog[] + sentMessages ChatMessage[] @relation("SentMessages") + receivedMessages ChatMessage[] @relation("ReceivedMessages") + accounts Account[] + sessions Session[] + stores Store[] // Changed from singular to support multiple stores + supplierProfile SupplierProfile? + notifications Notification[] + gmailIntegration GmailIntegration? + emailLogs EmailLog[] gmailInboundAutoReplies GmailInboundAutoReply[] - pushSubscriptions PushSubscription[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + pushSubscriptions PushSubscription[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } // Auth.js / NextAuth Prisma adapter models (required for OAuth e.g. Google) @@ -57,12 +57,12 @@ model Account { type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? @db.Text + access_token String? @db.Text expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -88,101 +88,122 @@ model VerificationToken { } model Store { - id String @id @default(cuid()) - userId String // Removed @unique to allow multiple stores per user - user User @relation(fields: [userId], references: [id]) - - businessName String - email String - timezone String - country String - + id String @id @default(cuid()) + userId String // Removed @unique to allow multiple stores per user + user User @relation(fields: [userId], references: [id]) + + businessName String + email String + timezone String + country String + // Shopify Credentials - shopifyStoreName String - shopifyDomain String - shopifyApiKey String - shopifyApiSecret String + shopifyStoreName String + shopifyDomain String + shopifyApiKey String + shopifyApiSecret String shopifyAccessToken String - + onboardingComplete Boolean @default(false) - isActive Boolean @default(true) // Track if this is the active store + isActive Boolean @default(true) // Track if this is the active store // Customer-facing storefront widget (floating chat) enablement. - storefrontChatEnabled Boolean @default(false) - /** After OTP verify, widget session length (minutes). Clamped server-side (e.g. 5–10080). Default 60. */ - storefrontOtpSessionMinutes Int @default(60) - storefrontChatName String? - storefrontChatTagline String? - storefrontChatBrandColor String? - storefrontChatFontFamily String? - storefrontChatLogoUrl String? + storefrontChatEnabled Boolean @default(false) + /** + * After OTP verify, widget session length (minutes). Clamped server-side (e.g. 5–10080). Default 60. + */ + storefrontOtpSessionMinutes Int @default(60) + storefrontChatName String? + storefrontChatTagline String? + storefrontChatBrandColor String? + storefrontChatFontFamily String? + storefrontChatLogoUrl String? - /** Primary knowledge source: structured questionnaire vs uploaded documents (mutually exclusive). */ - knowledgeBaseMode KnowledgeBaseMode @default(UNSET) - /** Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. */ - knowledgeTemplate Json? + /** + * Primary knowledge source: structured questionnaire vs uploaded documents (mutually exclusive). + */ + knowledgeBaseMode KnowledgeBaseMode @default(UNSET) + /** + * Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. + */ + 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) - /** Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. */ - aiModules Json? - /** FAQ-style policy text per L1 module for embeddings, e.g. { "ORDER_STATUS": "..." }. */ - modulePolicies Json? - /** Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */ - sharedFieldAnswers Json? + /** + * Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. + */ + aiModules Json? + /** + * FAQ-style policy text per L1 module for embeddings, e.g. { "ORDER_STATUS": "..." }. + */ + modulePolicies Json? + /** + * Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). + */ + sharedFieldAnswers Json? - /** Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. */ - gmailReplyTheme Json? + /** + * Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. + */ + gmailReplyTheme Json? /** * When set, supplier-reported stock (ProductSync snapshot) below this value can trigger * B2B low-stock chat to linked suppliers. Null = off for this signal. */ - lowStockThreshold Int? + lowStockThreshold Int? /** * When set, total Shopify variant inventory (sum of inventory_quantity) below this value * triggers an in-app notification to the vendor (restock / Load stock). Null = off. */ - lowStockShopifyThreshold Int? + lowStockShopifyThreshold Int? // Relations - connections Connection[] - cachedProducts CachedProduct[] - cachedOrders CachedOrder[] - cachedCustomers CachedCustomer[] - storefrontChatConversations StorefrontChatConversation[] - syncMetadata SyncMetadata[] - invitations Invitation[] - productSyncs ProductSync[] - supplierProductOffers SupplierProductOffer[] - knowledgeDocuments KnowledgeDocument[] - faqItems FaqItem[] - knowledgeChunks KnowledgeChunk[] - emailLogs EmailLog[] - flaggedEmails FlaggedEmail[] - gmailInboundAutoReplies GmailInboundAutoReply[] - templates Template[] - chatMessages ChatMessage[] - lowStockAlertStates LowStockAlertState[] + connections Connection[] + cachedProducts CachedProduct[] + cachedOrders CachedOrder[] + cachedCustomers CachedCustomer[] + storefrontChatConversations StorefrontChatConversation[] + syncMetadata SyncMetadata[] + invitations Invitation[] + productSyncs ProductSync[] + supplierProductOffers SupplierProductOffer[] + knowledgeDocuments KnowledgeDocument[] + faqItems FaqItem[] + knowledgeChunks KnowledgeChunk[] + emailLogs EmailLog[] + flaggedEmails FlaggedEmail[] + gmailInboundAutoReplies GmailInboundAutoReply[] + templates Template[] + chatMessages ChatMessage[] + aiAnalyticsEvents AiAnalyticsEvent[] + lowStockAlertStates LowStockAlertState[] lowStockShopifyVendorAlertStates LowStockShopifyVendorAlertState[] - supplierAutomationLogs SupplierAutomationLog[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) // Add index for performance + supplierAutomationLogs SupplierAutomationLog[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@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 { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - shopifyProductId String - supplierId String - supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + shopifyProductId String + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade) 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? createdAt DateTime @default(now()) @@ -193,15 +214,19 @@ model LowStockAlertState { @@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 { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - shopifyProductId String - lastSeenShopifyTotal Int? - /** Set when we created a vendor notification; cleared when Shopify total is back >= threshold. */ - lastNotifiedBelowAt DateTime? + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + shopifyProductId String + lastSeenShopifyTotal Int? + /** + * Set when we created a vendor notification; cleared when Shopify total is back >= threshold. + */ + lastNotifiedBelowAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -216,14 +241,14 @@ enum SupplierAutomationKind { } model SupplierAutomationLog { - id String @id @default(cuid()) + id String @id @default(cuid()) storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) kind SupplierAutomationKind shopifyProductId String? supplierUserId String? ticketKey String? - messagePreview String @db.Text + messagePreview String @db.Text createdAt DateTime @default(now()) @@ -232,151 +257,157 @@ model SupplierAutomationLog { } model CachedProduct { - id String @id @default(cuid()) - shopifyProductId String - storeId String - title String - vendor String? - productType String? - status String? - variants Json // Store variants as JSON - images Json // Store images as JSON - /** Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search. */ - metafields Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastSyncedAt DateTime @default(now()) - - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + shopifyProductId String + storeId String + title String + vendor String? + productType String? + status String? + variants Json // Store variants as JSON + images Json // Store images as JSON + /** + * Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search. + */ + metafields Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + @@unique([shopifyProductId, storeId]) @@index([storeId]) } model CachedOrder { - id String @id @default(cuid()) - shopifyOrderId String - storeId String - orderNumber String - email String? - totalPrice String - currency String? - financialStatus String? - fulfillmentStatus String? - /** When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). */ - holdReasonCode String? - holdNote String? @db.Text - trackingNumber String? - trackingCompany String? - trackingUrl String? - pendingVendorSync Boolean @default(false) - lastSupplierUpdateAt DateTime? + id String @id @default(cuid()) + shopifyOrderId String + storeId String + orderNumber String + email String? + totalPrice String + currency String? + financialStatus String? + fulfillmentStatus String? + /** + * When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). + */ + holdReasonCode String? + holdNote String? @db.Text + trackingNumber String? + trackingCompany String? + trackingUrl String? + pendingVendorSync Boolean @default(false) + lastSupplierUpdateAt DateTime? lastSupplierUpdatedBy String? - lastShopifySyncAt DateTime? - lastShopifySyncError String? - lineItems Json // Store line items as JSON - customer Json? // Store customer info as JSON - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastSyncedAt DateTime @default(now()) - shopifyCreatedAt DateTime? - - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - + lastShopifySyncAt DateTime? + lastShopifySyncError String? + lineItems Json // Store line items as JSON + customer Json? // Store customer info as JSON + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) + shopifyCreatedAt DateTime? + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + @@unique([shopifyOrderId, storeId]) @@index([storeId]) @@index([shopifyCreatedAt]) } model CachedCustomer { - id String @id @default(cuid()) + id String @id @default(cuid()) shopifyCustomerId String storeId String email String? firstName String? lastName String? - ordersCount Int @default(0) + ordersCount Int @default(0) totalSpent String? phone 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? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastSyncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) shopifyCreatedAt DateTime? - - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + @@unique([shopifyCustomerId, storeId]) @@index([storeId]) } model SyncMetadata { - id String @id @default(cuid()) - storeId String - resourceType String // "products", "orders", "customers" - lastSyncAt DateTime @default(now()) - syncStatus String @default("success") // "success", "failed", "in_progress" - recordCount Int @default(0) - errorMessage String? - - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + storeId String + resourceType String // "products", "orders", "customers" + lastSyncAt DateTime @default(now()) + syncStatus String @default("success") // "success", "failed", "in_progress" + recordCount Int @default(0) + errorMessage String? + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + @@unique([storeId, resourceType]) @@index([storeId]) } model SupplierProfile { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id]) - - companyName String - description String? - - // Relations - connections Connection[] - products Product[] - invitations Invitation[] - productSyncs ProductSync[] - supplierProductOffers SupplierProductOffer[] - lowStockAlertStates LowStockAlertState[] + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + companyName String + description String? + + // Relations + connections Connection[] + products Product[] + invitations Invitation[] + productSyncs ProductSync[] + supplierProductOffers SupplierProductOffer[] + lowStockAlertStates LowStockAlertState[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Connection { - id String @id @default(cuid()) + id String @id @default(cuid()) storeId String - store Store @relation(fields: [storeId], references: [id]) + store Store @relation(fields: [storeId], references: [id]) supplierId String supplier SupplierProfile @relation(fields: [supplierId], references: [id]) - - status ConnectionStatus @default(PENDING) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + status ConnectionStatus @default(PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + supplierProductOffers SupplierProductOffer[] @@unique([storeId, supplierId]) - supplierProductOffers SupplierProductOffer[] } model Invitation { - id String @id @default(cuid()) - token String @unique - storeId String - store Store @relation(fields: [storeId], references: [id]) - supplierId String - supplier SupplierProfile @relation(fields: [supplierId], references: [id]) - - email String // Supplier email to send invitation to - expiresAt DateTime - used Boolean @default(false) - - createdAt DateTime @default(now()) - usedAt DateTime? + id String @id @default(cuid()) + token String @unique + storeId String + store Store @relation(fields: [storeId], references: [id]) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + email String // Supplier email to send invitation to + expiresAt DateTime + used Boolean @default(false) + + createdAt DateTime @default(now()) + usedAt DateTime? @@index([token]) @@index([supplierId]) @@ -390,37 +421,37 @@ enum ConnectionStatus { } model Product { - id String @id @default(cuid()) - supplierId String - supplier SupplierProfile @relation(fields: [supplierId], references: [id]) - + id String @id @default(cuid()) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + title String description String? price Float sku String? imageUrl String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - offers SupplierProductOffer[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + offers SupplierProductOffer[] } model SupplierProductOffer { - id String @id @default(cuid()) + id String @id @default(cuid()) productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) connectionId String - connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) supplierId String supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade) - price Float - quantity Int + price Float + quantity Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([productId, storeId]) @@index([storeId]) @@ -435,26 +466,26 @@ enum ProductSyncStatus { } model ProductSync { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id]) - supplierId String - supplier SupplierProfile @relation(fields: [supplierId], references: [id]) - - shopifyProductId String // Shopify product ID from vendor's store + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id]) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + shopifyProductId String // Shopify product ID from vendor's store shopifyProductTitle String - shopifyProductData Json? // Store full product data for reference - - status ProductSyncStatus @default(PENDING) - + shopifyProductData Json? // Store full product data for reference + + status ProductSyncStatus @default(PENDING) + // Metadata - requestedBy String // userId of vendor who requested + requestedBy String // userId of vendor who requested acceptedAt DateTime? rejectedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([storeId, supplierId, shopifyProductId]) @@index([storeId]) @@index([supplierId]) @@ -463,20 +494,20 @@ model ProductSync { } model GmailIntegration { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - accessToken String @db.Text - refreshToken String? @db.Text - expiresAt DateTime - - email String // Gmail email address - scope String // OAuth scopes granted - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + accessToken String @db.Text + refreshToken String? @db.Text + expiresAt DateTime + + email String // Gmail email address + scope String // OAuth scopes granted + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([userId]) } @@ -493,28 +524,30 @@ enum NotificationType { } model Notification { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - type NotificationType - title String - message String - read Boolean @default(false) - readAt DateTime? - + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type NotificationType + title String + message String + read Boolean @default(false) + readAt DateTime? + // Optional metadata for linking to related entities - metadata Json? // Store related IDs (connectionId, invitationId, etc.) - + metadata Json? // Store related IDs (connectionId, invitationId, etc.) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + @@index([userId]) @@index([userId, read]) @@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 { id String @id @default(cuid()) userId String @@ -529,26 +562,26 @@ model GmailInboundAutoReply { } model EmailLog { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - storeId String? - store Store? @relation(fields: [storeId], references: [id]) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + storeId String? + store Store? @relation(fields: [storeId], references: [id]) to String subject String bodyPreview String? // Whether this email was triggered automatically by the system - isAutomated Boolean @default(false) - trigger String? // e.g. "GMAIL_AUTO_REPLY", "TICKET_CREATED" + isAutomated Boolean @default(false) + trigger String? // e.g. "GMAIL_AUTO_REPLY", "TICKET_CREATED" - status String // "SENT" | "FAILED" + status String // "SENT" | "FAILED" providerMessageId String? - errorMessage String? + errorMessage String? - sentAt DateTime @default(now()) - createdAt DateTime @default(now()) + sentAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([storeId, sentAt]) @@index([userId, sentAt]) @@ -561,16 +594,16 @@ enum FlaggedEmailPriority { } model FlaggedEmail { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - subject String @db.Text - from String // Sender email - reason String @db.Text - priority FlaggedEmailPriority @default(MEDIUM) - messageId String? // Gmail message id for future sync - flaggedAt DateTime @default(now()) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + subject String @db.Text + from String // Sender email + reason String @db.Text + priority FlaggedEmailPriority @default(MEDIUM) + messageId String? // Gmail message id for future sync + flaggedAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([storeId]) @@index([storeId, flaggedAt]) @@ -596,40 +629,40 @@ enum KnowledgeBaseMode { } model KnowledgeDocument { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - title String - fileName String - mimeType String - sizeBytes Int - status KnowledgeDocumentStatus @default(UPLOADED) + title String + fileName String + mimeType String + sizeBytes Int + status KnowledgeDocumentStatus @default(UPLOADED) errorMessage String? // Where the raw file is stored (local path or object storage key) storagePath String // Relations - chunks KnowledgeChunk[] + chunks KnowledgeChunk[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([storeId]) } model FaqItem { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - question String - answer String - tags Json? + question String + answer String + tags Json? // Relations - chunks KnowledgeChunk[] + chunks KnowledgeChunk[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -638,25 +671,25 @@ model FaqItem { } model KnowledgeChunk { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - documentId String? - document KnowledgeDocument? @relation(fields: [documentId], references: [id]) + documentId String? + document KnowledgeDocument? @relation(fields: [documentId], references: [id]) - faqItemId String? - faqItem FaqItem? @relation(fields: [faqItemId], references: [id]) + faqItemId String? + faqItem FaqItem? @relation(fields: [faqItemId], references: [id]) - sourceType KnowledgeChunkSourceType - content String - metadata Json? + sourceType KnowledgeChunkSourceType + content String + metadata Json? // pgvector-backed column; accessed via raw SQL for similarity search - embedding Unsupported("vector") + embedding Unsupported("vector") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([storeId]) @@index([documentId]) @@ -664,37 +697,39 @@ model KnowledgeChunk { } model Template { - id String @id @default(cuid()) - storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) name String intent String mood String description String? - body String // Template body (HTML or plain text with {{variables}}) - variables Json? // Optional metadata describing expected variables - isHtml Boolean @default(true) + body String // Template body (HTML or plain text with {{variables}}) + variables Json? // Optional metadata describing expected variables + isHtml Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([storeId]) } model ChatMessage { - id String @id @default(cuid()) - senderId String - receiverId String - /** Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading. */ - storeId String? - kind ChatMessageKind @default(TEXT) - content String @db.Text - attachment Json? + id String @id @default(cuid()) + senderId String + receiverId String + /** + * Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading. + */ + storeId String? + kind ChatMessageKind @default(TEXT) + content String @db.Text + attachment Json? - sender User @relation("SentMessages", fields: [senderId], references: [id]) - receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) + sender User @relation("SentMessages", fields: [senderId], references: [id]) + receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @@ -710,57 +745,94 @@ enum StorefrontChatSenderType { } model StorefrontChatConversation { - id String @id @default(cuid()) + id String @id @default(cuid()) storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) visitorId String customerEmail String? - /** Email the customer entered for OTP (asserted order email). */ + /** + * Email the customer entered for OTP (asserted order email). + */ assertedEmail String? emailVerifiedAt DateTime? - /** Verified session TTL (e.g. 1h from verification). */ + /** + * Verified session TTL (e.g. 1h from verification). + */ sessionExpiresAt DateTime? - /** After order disambiguation, pinned Shopify order id for this session. */ + /** + * After order disambiguation, pinned Shopify order id for this session. + */ 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? otpExpiresAt DateTime? - /** L1 orchestrator: phase, module, slots (JSON). */ + /** + * L1 orchestrator: phase, module, slots (JSON). + */ l1State Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - messages StorefrontChatMessage[] + messages StorefrontChatMessage[] @@unique([storeId, visitorId]) @@index([storeId]) } model StorefrontChatMessage { - id String @id @default(cuid()) + id String @id @default(cuid()) conversationId String - conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - senderType StorefrontChatSenderType - content String @db.Text - actions Json? + conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + senderType StorefrontChatSenderType + content String @db.Text + actions Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@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 { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - endpoint String @unique - p256dh String // Public key for encryption - auth String // Authentication secret - - userAgent String? // Optional: device/browser info + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + endpoint String @unique + p256dh String // Public key for encryption + auth String // Authentication secret + + userAgent String? // Optional: device/browser info createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/dashboard/ai-analytics/route.test.ts b/src/app/api/dashboard/ai-analytics/route.test.ts new file mode 100644 index 0000000..f28a5e9 --- /dev/null +++ b/src/app/api/dashboard/ai-analytics/route.test.ts @@ -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) + }) +}) diff --git a/src/app/api/dashboard/ai-analytics/route.ts b/src/app/api/dashboard/ai-analytics/route.ts index 3a214b9..8395f87 100644 --- a/src/app/api/dashboard/ai-analytics/route.ts +++ b/src/app/api/dashboard/ai-analytics/route.ts @@ -1,10 +1,75 @@ import { NextResponse } from "next/server" -import { format, startOfMonth, subMonths } from "date-fns" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" 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 + orderBy: { createdAt: "asc" } + }) => Promise + } +} function median(nums: number[]): number { 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 } -function monthKeys(monthCount: number): string[] { - const keys: string[] = [] - for (let i = monthCount - 1; i >= 0; i--) { - keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM")) - } - return keys +function pct(part: number, total: number): number { + if (total <= 0) return 0 + return Math.round((part / total) * 1000) / 10 } -function monthLabel(isoMonth: string): string { - const [y, m] = isoMonth.split("-").map(Number) - return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") +function money(amount: number): number { + return Math.round(amount * 100) / 100 } -export async function GET() { +function addToTimeline( + 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 { const session = await auth() if (!session?.user?.id) { @@ -41,65 +123,149 @@ export async function GET() { 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 since = startOfMonth(subMonths(new Date(), 5)) + const prismaWithAnalytics = prisma as PrismaWithAnalytics - const [ - storefrontMessages, - gmailAutoReplies, - attributedConversations, - chatMessages, - ] = await Promise.all([ - prisma.storefrontChatMessage.findMany({ - where: { - conversation: { storeId: store.id }, - createdAt: { gte: since }, - }, - select: { - senderType: true, - createdAt: true, - content: true, - conversationId: true, - }, - orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], - }), - prisma.gmailInboundAutoReply.findMany({ - where: { storeId: store.id, createdAt: { gte: since } }, - select: { createdAt: true }, - }), - prisma.storefrontChatConversation.findMany({ - where: { - storeId: store.id, - boundShopifyOrderId: { not: null }, - updatedAt: { gte: subMonths(new Date(), 1) }, - }, - select: { - id: true, - visitorId: true, - customerEmail: true, - boundShopifyOrderId: true, - updatedAt: true, - }, - orderBy: { updatedAt: "desc" }, - take: 25, - }), - prisma.chatMessage.findMany({ - where: { storeId: store.id, createdAt: { gte: since } }, - select: { senderId: true, receiverId: true, createdAt: true }, - orderBy: { createdAt: "asc" }, - take: 8000, - }), - ]) + const [storefrontMessages, analyticsEvents, conversations, cachedOrders, chatMessages]: [ + StorefrontMessageRow[], + AnalyticsEventRow[], + ConversationRow[], + CachedOrderRow[], + ChatMessageRow[], + ] = + await Promise.all([ + prisma.storefrontChatMessage.findMany({ + where: { + conversation: { storeId: store.id }, + createdAt: { gte: window.since, lte: window.until }, + }, + select: { + senderType: true, + createdAt: true, + content: true, + conversationId: true, + }, + orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], + }), + prismaWithAnalytics.aiAnalyticsEvent.findMany({ + where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } }, + 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({ + where: { storeId: store.id, updatedAt: { gte: window.since } }, + select: { + id: true, + visitorId: true, + customerEmail: true, + boundShopifyOrderId: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { updatedAt: "asc" }, + }), + prisma.cachedOrder.findMany({ + where: { + storeId: store.id, + OR: [ + { shopifyCreatedAt: { gte: window.since, lte: window.until } }, + { createdAt: { gte: window.since, lte: window.until } }, + ], + }, + select: { + shopifyOrderId: true, + orderNumber: true, + email: true, + totalPrice: true, + currency: true, + lineItems: true, + shopifyCreatedAt: true, + createdAt: true, + }, + }), + prisma.chatMessage.findMany({ + where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } }, + select: { senderId: true, receiverId: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + take: 8000, + }), + ]) - const keys = monthKeys(6) - const aiShareByMonth: Record = Object.fromEntries( - keys.map((k) => [k, { ai: 0, cust: 0 }]), - ) - const gmailByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) + const revenueAttribution = computeAiRevenueAttribution({ + events: analyticsEvents, + conversations, + orders: cachedOrders, + }) - for (const g of gmailAutoReplies) { - const k = format(startOfMonth(g.createdAt), "yyyy-MM") - if (k in gmailByMonth) gmailByMonth[k] += 1 + const timeline = emptyTimeline(window.labels, window.grain, { + directUpsellRevenue: 0, + 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 = { @@ -109,144 +275,157 @@ export async function GET() { Shipping: 0, General: 0, } - + const productQuestionCounts = new Map() + const pendingByConversation = new Map() const aiLatencySeconds: number[] = [] - const aiLatencyByMonth: Record = Object.fromEntries(keys.map((k) => [k, []])) - let pendingCustomer: { conversationId: string; createdAt: Date } | null = null - for (const m of storefrontMessages) { - const monthKey = format(startOfMonth(m.createdAt), "yyyy-MM") - if (monthKey in aiShareByMonth) { - if (m.senderType === "AI") aiShareByMonth[monthKey]!.ai += 1 - if (m.senderType === "CUSTOMER") aiShareByMonth[monthKey]!.cust += 1 - } - - if (m.senderType === "CUSTOMER") { - intentCounts[classifyStorefrontCustomerQuery(m.content)] += 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 + for (const message of storefrontMessages) { + if (message.senderType === "CUSTOMER") { + const bucket = classifyStorefrontCustomerQuery(message.content) + intentCounts[bucket] += 1 + if (bucket === "Product Qs") { + const name = productQuestionLabel(message.content) + productQuestionCounts.set(name, { + name, + count: (productQuestionCounts.get(name)?.count ?? 0) + 1, + }) + } + 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 automationSeries = keys.map((key) => { - const { ai, cust } = aiShareByMonth[key] ?? { ai: 0, cust: 0 } - const vol = ai + cust - const storefrontAiSharePct = vol > 0 ? Math.round((ai / vol) * 1000) / 10 : 0 - return { - monthKey: key, - month: monthLabel(key), - storefrontAiSharePct, - gmailAutoReplies: gmailByMonth[key] ?? 0, - volumes: { aiMessages: ai, customerMessages: cust }, - } - }) - const manualLatencyMinutes: number[] = [] - const manualLatencyByMonth: Record = Object.fromEntries(keys.map((k) => [k, []])) for (let i = 0; i < chatMessages.length - 1; i++) { const a = chatMessages[i]! const b = chatMessages[i + 1]! - if ( - a.receiverId === vendorUserId && - a.senderId !== vendorUserId && - b.senderId === vendorUserId - ) { + if (a.receiverId === vendorUserId && a.senderId !== vendorUserId && b.senderId === vendorUserId) { const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000 - manualLatencyMinutes.push(min) - const mk = format(startOfMonth(b.createdAt), "yyyy-MM") - if (mk in manualLatencyByMonth) manualLatencyByMonth[mk]!.push(min) + if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min) } } - const responseTimeByMonth = keys.map((k) => ({ - month: monthLabel(k), - aiMinutes: Math.round((median(aiLatencyByMonth[k] ?? []) / 60) * 10) / 10, - manualMinutes: Math.round(median(manualLatencyByMonth[k] ?? []) * 10) / 10, - })) + const questions = + analyticsEvents.filter((event) => event.eventType === "customer_question").length || + storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length + 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 - .map((c) => c.boundShopifyOrderId) - .filter(Boolean) as string[] - - const cachedOrders = - orderIds.length > 0 - ? 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(), - } - }) - .filter(Boolean) - - const attributedTotal = attributedRows.reduce((sum, r) => sum + (r?.amount ?? 0), 0) - - const signalEvents = storefrontMessages.length + gmailAutoReplies.length - const modelEvaluation = buildModelEvaluationSnapshot(store.id, { - signalEvents, - monthLabels: automationSeries.map((r) => r.month), - }) + const sentimentTotals = { + positive: analyticsEvents.filter((event) => event.sentiment === "positive").length, + neutral: analyticsEvents.filter((event) => event.sentiment === "neutral").length, + negative: analyticsEvents.filter((event) => event.sentiment === "negative").length, + urgent: analyticsEvents.filter((event) => event.sentiment === "urgent").length, + unknown: analyticsEvents.filter((event) => event.sentiment === "unknown").length, + } + const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0) + const aiMedianSeconds = median(aiLatencySeconds) + const manualMedianMinutes = median(manualLatencyMinutes) + const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60) + const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10 + const topProductQuestions = Array.from(productQuestionCounts.values()) + .sort((a, b) => b.count - a.count) + .slice(0, 8) + 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({ storeId: store.id, generatedAt: new Date().toISOString(), - modelEvaluation, - automationSeries, - messageVolumeByType: Object.entries(intentCounts).map(([name, count]) => ({ name, count })), - responseTime: { - aiMedianSeconds: Math.round(median(aiLatencySeconds)), - manualMedianMinutes: Math.round(median(manualLatencyMinutes) * 10) / 10, + range: window.range, + grain: window.grain, + window: { + since: window.since.toISOString(), + until: window.until.toISOString(), + }, + 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, sampleManualPairs: manualLatencyMinutes.length, - byMonth: responseTimeByMonth, + hoursSavedEstimate, + resolutionRate: pct(resolvedByAi, questions), }, - attributedSupportSales: { - totalAmount: Math.round(attributedTotal * 100) / 100, - currency: cachedOrders[0]?.currency ?? "GBP", - rows: attributedRows.slice(0, 12), + demandSignals: { + curiousProductQuestionCount: intentCounts["Product Qs"], + topProductQuestions, + 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: { - storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length, - storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER") - .length, - gmailAutoReplies: gmailAutoReplies.length, + questions, + aiAnswers, + cartClicks, + cartOpenCount, }, }) } catch (e) { diff --git a/src/app/api/storefront-chat/events/route.ts b/src/app/api/storefront-chat/events/route.ts new file mode 100644 index 0000000..d315552 --- /dev/null +++ b/src/app/api/storefront-chat/events/route.ts @@ -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() + 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 }) + } +} diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 4de9efb..e951bd8 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -25,6 +25,12 @@ import { selectVariantForCart, } from "@/lib/storefront-chat/cartLinks" import { productSearchHitToActionProduct, searchProductsForStore } from "@/lib/ai/tools/productTools" +import { + classifyAiResolution, + classifyCustomerSentiment, + logAiAnalyticsEvent, + logAiAnalyticsEvents, +} from "@/lib/analytics/aiEvents" function normalizeShopDomain(input: string) { return input @@ -423,6 +429,39 @@ export async function POST(req: Request) { 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 existingCartLines = parseCartLines(conversation.l1State) const cartResponse = action @@ -463,6 +502,50 @@ export async function POST(req: Request) { 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({ where: { id: conversation.id }, data: { @@ -581,6 +664,68 @@ export async function POST(req: Request) { 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) { await prisma.storefrontChatConversation.update({ where: { id: conversation.id }, @@ -612,6 +757,18 @@ export async function POST(req: Request) { 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) { console.error("[STOREFRONT_TICKET_CREATE_ERR]", ticketErr) diff --git a/src/app/dashboard/ai-analytics/page.tsx b/src/app/dashboard/ai-analytics/page.tsx index ec659d6..9522830 100644 --- a/src/app/dashboard/ai-analytics/page.tsx +++ b/src/app/dashboard/ai-analytics/page.tsx @@ -7,82 +7,107 @@ import { Bar, BarChart, CartesianGrid, + Cell, Legend, Line, LineChart, - ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, - Cell, } from "recharts" import { Loader2, Sparkles } from "lucide-react" import { useTheme } from "@/components/ThemeProvider" import { chartTooltipProps } from "@/lib/charts/rechartsTooltip" import { cn } from "@/lib/utils" -type ModelEvaluationPayload = { - modelName: string - pipelineVersion: string - evaluationWindow: string - sampleSize: number - accuracyPct: number - precisionPct: number - recallPct: number - f1Pct: number - confusionMatrix: { - truePositive: number - falsePositive: number - falseNegative: number - trueNegative: number - } - monthlyF1Pct: { month: string; f1: number }[] +type TimelineRow = { + key: string + label: string + directUpsellRevenue: number + directCrossSellRevenue: number + aiAssistedRevenue: number + questions: number + resolvedByAi: number + unresolvedQuestions: number + ticketsCreated: number + upsellShown: number + upsellClicked: number + cartActions: number + positiveSentiment: number + neutralSentiment: number + negativeSentiment: number + urgentSentiment: number } type AiAnalyticsPayload = { - modelEvaluation: ModelEvaluationPayload - automationSeries: { - month: string - storefrontAiSharePct: number - gmailAutoReplies: number - volumes: { aiMessages: number; customerMessages: number } - }[] - messageVolumeByType: { name: string; count: number }[] - responseTime: { + generatedAt: string + range: string + grain: string + revenueImpact: { + currency: string + directUpsellRevenue: number + directCrossSellRevenue: number + aiAssistedRevenue: number + 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 manualMedianMinutes: number sampleAiPairs: number sampleManualPairs: number - byMonth: { month: string; aiMinutes: number; manualMinutes: number }[] + hoursSavedEstimate: number + resolutionRate: number } - attributedSupportSales: { - totalAmount: number - currency: string - rows: { - id: string - initials: string - label: string - summary: string - amount: number | null - currency: string - channel: "AI" - updatedAt: string - }[] + demandSignals: { + curiousProductQuestionCount: number + topProductQuestions: { name: string; count: number }[] + highInterestProducts: { name: string; count: number }[] + questionMix: { name: string; count: number }[] } - totals: { + timeline: TimelineRow[] + technicalDiagnostics: { + hiddenByDefault: boolean storefrontAiMessages: number storefrontCustomerMessages: number - gmailAutoReplies: number + analyticsEvents: number + cachedOrders: number } } -const INTENT_COLORS: Record = { - WISMO: "#3b82f6", - Returns: "#f97316", - "Product Qs": "#a855f7", - Shipping: "#22c55e", - General: "#94a3b8", +const RANGE_OPTIONS = [ + { value: "today", label: "Today" }, + { value: "7d", label: "7 days" }, + { value: "30d", label: "30 days" }, + { value: "90d", label: "90 days" }, + { 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) { @@ -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 ( +
+ {children} +
+ ) +} + +function MetricCard({ + title, + value, + helper, + accent, + isLight, +}: { + title: string + value: string + helper: string + accent: string + isLight: boolean +}) { + return ( +
+

+ {title} +

+

{value}

+

{helper}

+
+ ) +} + export default function AiAnalyticsPage() { const { theme } = useTheme() const isLight = theme === "light" + const [range, setRange] = useState("30d") const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { let cancelled = false + setLoading(true) + setError(null) ;(async () => { 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) if (!res.ok) throw new Error(json?.message || "Failed to load analytics") if (!cancelled) setData(json) } 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 { if (!cancelled) setLoading(false) } @@ -117,63 +181,58 @@ export default function AiAnalyticsPage() { return () => { cancelled = true } - }, []) + }, [range]) const chartMuted = isLight ? "#64748b" : "#94a3b8" const gridStroke = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)" const tt = useMemo(() => chartTooltipProps(isLight), [isLight]) - - const barData = useMemo( - () => - (data?.messageVolumeByType ?? []).map((r) => ({ - ...r, - fill: INTENT_COLORS[r.name] ?? "#6366f1", - })), - [data?.messageVolumeByType], - ) - - const responseBars = useMemo(() => { - 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]) + const timeline = data?.timeline ?? [] + const sentimentBars = useMemo(() => { + const totals = data?.customerExperience.sentimentMix.totals + if (!totals) return [] + return [ + { name: "Positive", count: totals.positive, fill: SENTIMENT_COLORS.positive }, + { name: "Neutral", count: totals.neutral, fill: SENTIMENT_COLORS.neutral }, + { name: "Negative", count: totals.negative, fill: SENTIMENT_COLORS.negative }, + { name: "Urgent", count: totals.urgent, fill: SENTIMENT_COLORS.urgent }, + ] + }, [data?.customerExperience.sentimentMix.totals]) return (
-
+
-

- AI Analytics +

+ AI Business Analytics

-

- Live metrics from storefront AI replies, Gmail automation, JUNO Chat response times, and - storefront sessions linked to Shopify orders. +

+ Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.

- {data && ( -
- Last 6 months · AI msgs - {data.totals.storefrontAiMessages} - · Gmail auto - {data.totals.gmailAutoReplies} -
- )} +
+ {RANGE_OPTIONS.map((option) => ( + + ))} +
{loading && ( @@ -181,418 +240,186 @@ export default function AiAnalyticsPage() {
)} + {error && ( -
+
{error}
)} {!loading && data && ( <> -
-
-
-

- Model performance -

-

- Intent routing and reply-quality classification tracked against assistant-handled - conversations on your storefront and Gmail automation pipeline. -

-
-
-

- Model - {data.modelEvaluation.modelName} -

-

- Release - {data.modelEvaluation.pipelineVersion} -

-

{data.modelEvaluation.evaluationWindow}

-

- Weighted eval samples - - {data.modelEvaluation.sampleSize.toLocaleString()} - -

-
-
+
+ + + + +
-
- {( - [ - { 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) => ( -
-

- {m.label} -

-

- {m.value} - % -

+
+
+
+
+

Revenue Impact Timeline

+

{data.revenueImpact.methodology}

- ))} -
- -
-
-

- F1 score trend -

-
+ + {data.grain} view + +
+ {timeline.length === 0 ? ( + Not enough data yet to draw a revenue timeline. + ) : ( +
- + - - `${v}%`} - /> - [`${Number(v ?? 0)}%`, "F1"]} /> - - + + + [formatMoney(Number(value ?? 0), data.revenueImpact.currency), String(name)]} /> + + + + +
-
+ )} +
-
-

- Confusion matrix (held-out) -

-
- - Predicted relevant - - - Predicted not relevant - +
+

Customer Sentiment

+

+ Conservative sentiment from storefront customer messages. Unknown is kept out of the chart. +

+ {sentimentBars.every((row) => row.count === 0) ? ( + Not enough sentiment data yet. + ) : ( +
+ + + + + + + + {sentimentBars.map((row) => ( + + ))} + + +
-
-
- Actual relevant - Actual not relevant -
-
-
-

True positive

-

{data.modelEvaluation.confusionMatrix.truePositive}

+ )} +
+
+ +
+
+

Automation Outcomes

+

+ Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed. +

+
+ + + + +
+
+ +
+

Questions And Resolutions

+

+ Timeline of demand, AI resolution, unresolved questions, and support tickets. +

+
+ + + + + + + + + + + + + +
+
+
+ +
+
+

Growth Signals

+

+ Product curiosity and repeated requests that can guide buying, merchandising, and product-page content. +

+ {data.demandSignals.topProductQuestions.length === 0 ? ( + Not enough product-question data yet. + ) : ( +
+ {data.demandSignals.topProductQuestions.map((row) => ( +
+ {row.name} + {row.count}
-
-

False negative

-

{data.modelEvaluation.confusionMatrix.falseNegative}

-
-
-

False positive

-

{data.modelEvaluation.confusionMatrix.falsePositive}

-
-
-

True negative

-

{data.modelEvaluation.confusionMatrix.trueNegative}

-
-
+ ))}
-
-
-
- -
-
-

- AI share of storefront traffic -

-

- AI messages ÷ (AI + customer messages) per month. Gmail auto-replies shown in tooltip. -

-
- - - - - - - - - - - `${v}%`} - domain={[0, 100]} - tick={{ fill: chartMuted, fontSize: 10, fontWeight: 600 }} - axisLine={false} - tickLine={false} - /> - { - const g = (item as { payload?: { gmailAutoReplies?: number } })?.payload - ?.gmailAutoReplies - return [`${Number(value ?? 0)}% (Gmail auto: ${g ?? 0})`, "AI share"] - }} - labelFormatter={(l) => String(l)} - /> - - - - -
-
-

- Customer topics (storefront) -

+
+

Attribution Health

- Classified from customer messages via keyword rules (WISMO, returns, products, shipping). + Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.

-
- - - - - - - - {barData.map((entry, i) => ( - - ))} - - - +
+ + + +
+ -
-

- Response time: AI vs manual -

-

- 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. -

-
- - - - - `${v}m`} - /> - - - name === "AI" - ? [`${Number(value ?? 0)} min (median)`, "AI"] - : [`${Number(value ?? 0)} min (median)`, "Manual"] - } - /> - - - - -
-

- Samples: AI pairs {data.responseTime.sampleAiPairs} · Manual pairs{" "} - {data.responseTime.sampleManualPairs}. Overall medians: AI{" "} - {(data.responseTime.aiMedianSeconds / 60).toFixed(1)} min · Manual{" "} - {data.responseTime.manualMedianMinutes.toFixed(1)} min -

+
+ + Technical Diagnostics + +
+

AI messages: {data.technicalDiagnostics.storefrontAiMessages}

+

Customer messages: {data.technicalDiagnostics.storefrontCustomerMessages}

+

Analytics events: {data.technicalDiagnostics.analyticsEvents}

+

Cached orders: {data.technicalDiagnostics.cachedOrders}

- -
-

- Revenue linked after AI storefront sessions -

-

- Orders pinned during verified storefront chat (last ~30 days). Totals use cached Shopify - orders for your active store. -

-
- {data.attributedSupportSales.rows.length === 0 ? ( -

- No bound orders yet — when customers verify and attach an order in chat, - conversions appear here. -

- ) : ( - data.attributedSupportSales.rows.map((row) => ( -
-
- {row.initials} -
-
-

- {row.label} -

-

- {row.summary} -

-
-
-

- {row.amount != null - ? formatMoney(row.amount, row.currency || data.attributedSupportSales.currency) - : "—"} -

- - AI session - -
-
- )) - )} -
-
- - Total attributed (window) - - - {formatMoney( - data.attributedSupportSales.totalAmount, - data.attributedSupportSales.currency, - )} - -
-
-
+ )}
diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 84b168d..424b565 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -442,6 +442,32 @@ export default function StorefrontChatWidgetPage() { ) } + const reportWidgetEvent = (message: WidgetMessage, action: Extract) => { + 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) => { if (message.senderType !== "AI" || !message.actions?.length) return null @@ -453,7 +479,10 @@ export default function StorefrontChatWidgetPage() {