Feature: upselling, add to cart modules
This commit is contained in:
parent
7ecdc917d6
commit
26fc46068f
1
.gitignore
vendored
1
.gitignore
vendored
@ -57,3 +57,4 @@ next-env.d.ts
|
|||||||
*.log
|
*.log
|
||||||
docker-data/
|
docker-data/
|
||||||
|
|
||||||
|
/Documents
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "StorefrontChatMessage" ADD COLUMN "actions" JSONB;
|
||||||
@ -744,6 +744,7 @@ model StorefrontChatMessage {
|
|||||||
conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
senderType StorefrontChatSenderType
|
senderType StorefrontChatSenderType
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
actions Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
import type { Prisma } from "@prisma/client"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import {
|
import {
|
||||||
storeSelectStorefrontChatApi,
|
storeSelectStorefrontChatApi,
|
||||||
@ -7,9 +8,23 @@ import {
|
|||||||
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
||||||
|
import type { StorefrontChatActionInput } from "@/lib/api-schemas/public"
|
||||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
import { extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
import { extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
||||||
import { getOrderDetailForVerifiedEmail } from "@/lib/ai/tools/orderTools"
|
import { getOrderDetailForVerifiedEmail } from "@/lib/ai/tools/orderTools"
|
||||||
|
import {
|
||||||
|
buildOpenCartAction,
|
||||||
|
normalizeActionProduct,
|
||||||
|
type StorefrontActionProduct,
|
||||||
|
type StorefrontChatAction,
|
||||||
|
} from "@/lib/storefront-chat/actions"
|
||||||
|
import {
|
||||||
|
buildCartPermalink,
|
||||||
|
extractCartableVariants,
|
||||||
|
formatVariantChoices,
|
||||||
|
selectVariantForCart,
|
||||||
|
} from "@/lib/storefront-chat/cartLinks"
|
||||||
|
import { productSearchHitToActionProduct, searchProductsForStore } from "@/lib/ai/tools/productTools"
|
||||||
|
|
||||||
function normalizeShopDomain(input: string) {
|
function normalizeShopDomain(input: string) {
|
||||||
return input
|
return input
|
||||||
@ -72,6 +87,196 @@ function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools?
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CartResponse = {
|
||||||
|
content: string
|
||||||
|
actions: StorefrontChatAction[]
|
||||||
|
pendingCartProduct: StorefrontActionProduct | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeStorefrontAction(action: StorefrontChatActionInput | undefined) {
|
||||||
|
if (!action) return ""
|
||||||
|
return action.type === "upsell_suggestion" ? "Add suggested item to cart" : "Add to cart"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCartConfirmationMessage(content: string) {
|
||||||
|
const text = content.trim().toLowerCase()
|
||||||
|
if (!text) return false
|
||||||
|
return /^(yes|yeah|yep|sure|ok|okay|please|do it|go ahead|add it|add to cart|buy it|i want to buy|i want it|yes please)[.! ]*$/i.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectAddToCartRequest(content: string) {
|
||||||
|
const text = content.trim().toLowerCase()
|
||||||
|
return /\b(add|put)\b.+\b(cart|bag)\b/.test(text) || /\b(buy|purchase)\b.+\b(now|it|this|product)\b/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingCartProduct(raw: unknown): StorefrontActionProduct | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null
|
||||||
|
const state = raw as Record<string, unknown>
|
||||||
|
return normalizeActionProduct(state.pendingCartProduct)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextL1State(raw: unknown, pendingCartProduct: StorefrontActionProduct | null) {
|
||||||
|
const current =
|
||||||
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||||
|
? { ...(raw as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
if (pendingCartProduct) {
|
||||||
|
current.pendingCartProduct = pendingCartProduct
|
||||||
|
current.pendingCartUpdatedAt = new Date().toISOString()
|
||||||
|
} else {
|
||||||
|
delete current.pendingCartProduct
|
||||||
|
delete current.pendingCartUpdatedAt
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonCompatible<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrlFromImages(images: unknown): string | null {
|
||||||
|
const rows = typeof images === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(images) as unknown
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: images
|
||||||
|
if (!Array.isArray(rows)) return null
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== "object") continue
|
||||||
|
const image = row as { src?: unknown; url?: unknown }
|
||||||
|
const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : ""
|
||||||
|
if (src.trim()) return src.trim()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCartResponse(params: {
|
||||||
|
storeId: string
|
||||||
|
shopifyDomain: string
|
||||||
|
productId: string
|
||||||
|
requestedVariantId?: string | null
|
||||||
|
quantity?: number
|
||||||
|
message: string
|
||||||
|
}): Promise<CartResponse> {
|
||||||
|
const product = await prisma.cachedProduct.findFirst({
|
||||||
|
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
||||||
|
select: {
|
||||||
|
shopifyProductId: true,
|
||||||
|
title: true,
|
||||||
|
productType: true,
|
||||||
|
variants: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
content: "I could not find that product in this store right now.",
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = extractCartableVariants(product.variants)
|
||||||
|
const selected = selectVariantForCart({
|
||||||
|
variants,
|
||||||
|
requestedVariantId: params.requestedVariantId,
|
||||||
|
message: params.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selected.status === "invalid_variant") {
|
||||||
|
return {
|
||||||
|
content: "I could not verify that product option for this store. Please choose the product again.",
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.status === "unavailable") {
|
||||||
|
return {
|
||||||
|
content: `${product.title} is not available to add to cart right now.`,
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.status === "needs_selection") {
|
||||||
|
return {
|
||||||
|
content: `Which option would you like for ${product.title}? Available options include: ${formatVariantChoices(selected.variants)}.`,
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: {
|
||||||
|
productId: product.shopifyProductId,
|
||||||
|
title: product.title,
|
||||||
|
imageUrl: imageUrlFromImages(product.images),
|
||||||
|
productType: product.productType,
|
||||||
|
quantity: params.quantity ?? 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Math.max(1, Math.min(10, Math.trunc(Number(params.quantity) || 1)))
|
||||||
|
const cartUrl = buildCartPermalink(params.shopifyDomain, [
|
||||||
|
{ variantId: selected.variant.id, quantity },
|
||||||
|
])
|
||||||
|
if (!cartUrl) {
|
||||||
|
return {
|
||||||
|
content: "I could not create a cart link for that product right now.",
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionProduct: StorefrontActionProduct = {
|
||||||
|
productId: product.shopifyProductId,
|
||||||
|
title: product.title,
|
||||||
|
variantId: selected.variant.id,
|
||||||
|
variantTitle: selected.variant.title,
|
||||||
|
sku: selected.variant.sku,
|
||||||
|
price: selected.variant.price,
|
||||||
|
imageUrl: imageUrlFromImages(product.images),
|
||||||
|
productType: product.productType,
|
||||||
|
quantity,
|
||||||
|
}
|
||||||
|
const variantText =
|
||||||
|
selected.variant.title && !/^default title$/i.test(selected.variant.title)
|
||||||
|
? ` (${selected.variant.title})`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: `Here is your cart link with ${product.title}${variantText} added: ${cartUrl}`,
|
||||||
|
actions: [buildOpenCartAction(cartUrl, actionProduct)],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDirectAddToCart(params: {
|
||||||
|
storeId: string
|
||||||
|
shopifyDomain: string
|
||||||
|
message: string
|
||||||
|
}): Promise<CartResponse | null> {
|
||||||
|
if (!isDirectAddToCartRequest(params.message)) return null
|
||||||
|
const hits = await searchProductsForStore(params.storeId, params.message, 1)
|
||||||
|
const hit = hits[0]
|
||||||
|
if (!hit) return null
|
||||||
|
const product = productSearchHitToActionProduct(hit)
|
||||||
|
return buildCartResponse({
|
||||||
|
storeId: params.storeId,
|
||||||
|
shopifyDomain: params.shopifyDomain,
|
||||||
|
productId: hit.shopifyProductId,
|
||||||
|
requestedVariantId: product?.variantId,
|
||||||
|
quantity: product?.quantity ?? 1,
|
||||||
|
message: params.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const rate = await applyRateLimit(req, "public:storefront-chat", {
|
const rate = await applyRateLimit(req, "public:storefront-chat", {
|
||||||
@ -90,7 +295,8 @@ export async function POST(req: Request) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return validationErrorResponse(parsed.error)
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
const { shop, visitorId, content: trimmedContent, customerEmail } = parsed.data
|
const { shop, visitorId, content: trimmedContent, customerEmail, action } = parsed.data
|
||||||
|
const customerContent = trimmedContent || describeStorefrontAction(action)
|
||||||
|
|
||||||
const normalizedShop = normalizeShopDomain(shop)
|
const normalizedShop = normalizeShopDomain(shop)
|
||||||
|
|
||||||
@ -143,11 +349,65 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderType: "CUSTOMER",
|
senderType: "CUSTOMER",
|
||||||
content: trimmedContent
|
content: customerContent
|
||||||
},
|
},
|
||||||
select: { id: true, createdAt: true, senderType: true, content: true }
|
select: { id: true, createdAt: true, senderType: true, content: true, actions: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pendingCartProduct = parsePendingCartProduct(conversation.l1State)
|
||||||
|
const cartResponse = action
|
||||||
|
? await buildCartResponse({
|
||||||
|
storeId: store.id,
|
||||||
|
shopifyDomain: store.shopifyDomain,
|
||||||
|
productId: action.productId,
|
||||||
|
requestedVariantId: action.variantId,
|
||||||
|
quantity: action.quantity,
|
||||||
|
message: customerContent,
|
||||||
|
})
|
||||||
|
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
||||||
|
? await buildCartResponse({
|
||||||
|
storeId: store.id,
|
||||||
|
shopifyDomain: store.shopifyDomain,
|
||||||
|
productId: pendingCartProduct.productId,
|
||||||
|
requestedVariantId: pendingCartProduct.variantId,
|
||||||
|
quantity: pendingCartProduct.quantity,
|
||||||
|
message: customerContent,
|
||||||
|
})
|
||||||
|
: await resolveDirectAddToCart({
|
||||||
|
storeId: store.id,
|
||||||
|
shopifyDomain: store.shopifyDomain,
|
||||||
|
message: customerContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cartResponse) {
|
||||||
|
const aiMessage = await prisma.storefrontChatMessage.create({
|
||||||
|
data: {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderType: "AI",
|
||||||
|
content: cartResponse.content,
|
||||||
|
actions: cartResponse.actions.length ? toJsonCompatible(cartResponse.actions) : undefined,
|
||||||
|
},
|
||||||
|
select: { id: true, createdAt: true, senderType: true, content: true, actions: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.storefrontChatConversation.update({
|
||||||
|
where: { id: conversation.id },
|
||||||
|
data: { l1State: toJsonInput(nextL1State(conversation.l1State, cartResponse.pendingCartProduct)) },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
messages: [customerMessage, aiMessage],
|
||||||
|
meta: {
|
||||||
|
module: "PRODUCT_FIT",
|
||||||
|
pillar: "K",
|
||||||
|
usedTools: ["cart_permalink"],
|
||||||
|
kbHits: 0,
|
||||||
|
productHits: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const recentHistory = await prisma.storefrontChatMessage.findMany({
|
const recentHistory = await prisma.storefrontChatMessage.findMany({
|
||||||
where: { conversationId: conversation.id },
|
where: { conversationId: conversation.id },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@ -228,16 +488,25 @@ export async function POST(req: Request) {
|
|||||||
: alreadyEscalated
|
: alreadyEscalated
|
||||||
? ESCALATION_ALREADY_DISPATCHED_REPLY
|
? ESCALATION_ALREADY_DISPATCHED_REPLY
|
||||||
: ai.content
|
: ai.content
|
||||||
|
const aiActions = aiContent === ai.content ? ai.actions ?? [] : []
|
||||||
|
|
||||||
const aiMessage = await prisma.storefrontChatMessage.create({
|
const aiMessage = await prisma.storefrontChatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderType: "AI",
|
senderType: "AI",
|
||||||
content: aiContent
|
content: aiContent,
|
||||||
|
actions: aiActions.length ? toJsonCompatible(aiActions) : undefined,
|
||||||
},
|
},
|
||||||
select: { id: true, createdAt: true, senderType: true, content: true }
|
select: { id: true, createdAt: true, senderType: true, content: true, actions: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (aiContent === ai.content) {
|
||||||
|
await prisma.storefrontChatConversation.update({
|
||||||
|
where: { id: conversation.id },
|
||||||
|
data: { l1State: toJsonInput(nextL1State(conversation.l1State, ai.pendingCartProduct ?? null)) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isEscalation &&
|
isEscalation &&
|
||||||
!alreadyEscalated &&
|
!alreadyEscalated &&
|
||||||
@ -256,7 +525,7 @@ export async function POST(req: Request) {
|
|||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
to: ticketTo,
|
to: ticketTo,
|
||||||
subject: ticketSubject,
|
subject: ticketSubject,
|
||||||
bodyPreview: trimmedContent.slice(0, 2000),
|
bodyPreview: customerContent.slice(0, 2000),
|
||||||
isAutomated: true,
|
isAutomated: true,
|
||||||
trigger: STOREFRONT_TICKET_TRIGGER,
|
trigger: STOREFRONT_TICKET_TRIGGER,
|
||||||
status: "SENT",
|
status: "SENT",
|
||||||
@ -322,7 +591,7 @@ export async function GET(req: Request) {
|
|||||||
where: { conversationId: conversation.id },
|
where: { conversationId: conversation.id },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
take: 120,
|
take: 120,
|
||||||
select: { id: true, content: true, senderType: true, createdAt: true }
|
select: { id: true, content: true, senderType: true, createdAt: true, actions: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, messages })
|
return NextResponse.json({ success: true, messages })
|
||||||
|
|||||||
@ -9,6 +9,47 @@ type WidgetMessage = {
|
|||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
senderType: "CUSTOMER" | "AI"
|
senderType: "CUSTOMER" | "AI"
|
||||||
|
actions?: WidgetAction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type WidgetActionProduct = {
|
||||||
|
productId: string
|
||||||
|
title: string
|
||||||
|
variantId?: string
|
||||||
|
variantTitle?: string | null
|
||||||
|
sku?: string | null
|
||||||
|
price?: string | null
|
||||||
|
imageUrl?: string | null
|
||||||
|
productType?: string | null
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WidgetAction =
|
||||||
|
| {
|
||||||
|
type: "add_to_cart"
|
||||||
|
label: string
|
||||||
|
source?: "primary" | "upsell"
|
||||||
|
product: WidgetActionProduct
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "upsell_suggestion"
|
||||||
|
label: string
|
||||||
|
message: string
|
||||||
|
product: WidgetActionProduct
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "open_cart"
|
||||||
|
label: string
|
||||||
|
cartUrl: string
|
||||||
|
product?: WidgetActionProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
type WidgetPostAction = {
|
||||||
|
type: "add_to_cart" | "upsell_suggestion"
|
||||||
|
productId: string
|
||||||
|
variantId?: string
|
||||||
|
quantity?: number
|
||||||
|
sourceMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyHttpUrl(value: string) {
|
function isLikelyHttpUrl(value: string) {
|
||||||
@ -63,6 +104,48 @@ function renderMessageContent(content: string) {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWidgetActions(raw: unknown): WidgetAction[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.filter((action): action is WidgetAction => {
|
||||||
|
if (!action || typeof action !== "object" || Array.isArray(action)) return false
|
||||||
|
const item = action as { type?: unknown; product?: unknown; cartUrl?: unknown }
|
||||||
|
if (item.type === "open_cart") return typeof item.cartUrl === "string"
|
||||||
|
if (item.type === "add_to_cart" || item.type === "upsell_suggestion") {
|
||||||
|
return Boolean(item.product && typeof item.product === "object")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(raw: unknown, fallback: string) {
|
||||||
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||||
|
const message = (raw as { message?: unknown }).message
|
||||||
|
if (typeof message === "string" && message.trim()) return message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiMessages(raw: unknown): unknown[] {
|
||||||
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||||
|
const messages = (raw as { messages?: unknown }).messages
|
||||||
|
return Array.isArray(messages) ? messages : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWidgetMessage(raw: unknown, fallbackId?: string): WidgetMessage {
|
||||||
|
const message = raw && typeof raw === "object" && !Array.isArray(raw)
|
||||||
|
? raw as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
return {
|
||||||
|
id: String(message.id || fallbackId || `local_${Date.now()}`),
|
||||||
|
content: String(message.content || ""),
|
||||||
|
senderType: (message.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
||||||
|
createdAt: String(message.createdAt || new Date().toISOString()),
|
||||||
|
actions: normalizeWidgetActions(message.actions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getOrCreateVisitorId(shop: string) {
|
function getOrCreateVisitorId(shop: string) {
|
||||||
const key = `juno_storefront_visitor_id_${shop}`
|
const key = `juno_storefront_visitor_id_${shop}`
|
||||||
try {
|
try {
|
||||||
@ -167,22 +250,15 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
)
|
)
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data?.message || "Failed to load history")
|
throw new Error(apiErrorMessage(data, "Failed to load history"))
|
||||||
}
|
}
|
||||||
const nextMessages = Array.isArray(data?.messages) ? data.messages : []
|
const nextMessages = apiMessages(data)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMessages(
|
setMessages(nextMessages.map((m) => toWidgetMessage(m)))
|
||||||
nextMessages.map((m: any) => ({
|
|
||||||
id: String(m.id),
|
|
||||||
content: String(m.content || ""),
|
|
||||||
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
|
||||||
createdAt: String(m.createdAt || new Date().toISOString())
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSendError(e?.message || "Failed to load chat history")
|
setSendError(e instanceof Error ? e.message : "Failed to load chat history")
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@ -299,10 +375,10 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = async () => {
|
const send = async (postAction?: WidgetPostAction, actionLabel?: string) => {
|
||||||
if (!shop || !visitorId) return
|
if (!shop || !visitorId) return
|
||||||
const text = composer.trim()
|
const text = postAction ? actionLabel || "Add to cart" : composer.trim()
|
||||||
if (!text) return
|
if (!text && !postAction) return
|
||||||
const typingStartedAt = Date.now()
|
const typingStartedAt = Date.now()
|
||||||
|
|
||||||
const optimisticCustomerMessage: WidgetMessage = {
|
const optimisticCustomerMessage: WidgetMessage = {
|
||||||
@ -313,7 +389,9 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages((prev) => [...prev, optimisticCustomerMessage])
|
setMessages((prev) => [...prev, optimisticCustomerMessage])
|
||||||
|
if (!postAction) {
|
||||||
setComposer("")
|
setComposer("")
|
||||||
|
}
|
||||||
setAiTyping(true)
|
setAiTyping(true)
|
||||||
setTimeout(() => composerRef.current?.focus(), 0)
|
setTimeout(() => composerRef.current?.focus(), 0)
|
||||||
|
|
||||||
@ -323,22 +401,17 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
const res = await fetch("/api/storefront-chat/messages", {
|
const res = await fetch("/api/storefront-chat/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ shop, visitorId, content: text })
|
body: JSON.stringify({ shop, visitorId, content: text, action: postAction })
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data?.message || "Failed to send message")
|
throw new Error(apiErrorMessage(data, "Failed to send message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const returned = Array.isArray(data?.messages) ? data.messages : []
|
const returned = apiMessages(data)
|
||||||
if (returned.length) {
|
if (returned.length) {
|
||||||
const returnedTyped = returned.map((m: any) => ({
|
const returnedTyped = returned.map((m) => toWidgetMessage(m))
|
||||||
id: String(m.id || `local_${Date.now()}`),
|
|
||||||
content: String(m.content || ""),
|
|
||||||
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
|
||||||
createdAt: String(m.createdAt || new Date().toISOString())
|
|
||||||
}))
|
|
||||||
const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI")
|
const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI")
|
||||||
const elapsed = Date.now() - typingStartedAt
|
const elapsed = Date.now() - typingStartedAt
|
||||||
const minTypingMs = 1000
|
const minTypingMs = 1000
|
||||||
@ -347,8 +420,8 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
setMessages(prev => [...prev, ...aiReplies])
|
setMessages(prev => [...prev, ...aiReplies])
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setSendError(e?.message || "Failed to send message")
|
setSendError(e instanceof Error ? e.message : "Failed to send message")
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id))
|
setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id))
|
||||||
} finally {
|
} finally {
|
||||||
setAiTyping(false)
|
setAiTyping(false)
|
||||||
@ -356,6 +429,94 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitCartAction = (message: WidgetMessage, action: Extract<WidgetAction, { type: "add_to_cart" | "upsell_suggestion" }>) => {
|
||||||
|
void send(
|
||||||
|
{
|
||||||
|
type: action.type,
|
||||||
|
productId: action.product.productId,
|
||||||
|
variantId: action.product.variantId,
|
||||||
|
quantity: action.product.quantity ?? 1,
|
||||||
|
sourceMessageId: message.id,
|
||||||
|
},
|
||||||
|
action.label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMessageActions = (message: WidgetMessage) => {
|
||||||
|
if (message.senderType !== "AI" || !message.actions?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{message.actions.map((action, index) => {
|
||||||
|
if (action.type === "open_cart") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${message.id}-${action.type}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "upsell_suggestion") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-${action.type}-${index}`}
|
||||||
|
className="rounded-xl border border-indigo-100 bg-indigo-50/70 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{action.product.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={action.product.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-12 w-12 flex-shrink-0 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-slate-800">
|
||||||
|
{action.message}
|
||||||
|
</p>
|
||||||
|
{action.product.price ? (
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
|
{action.product.price}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitCartAction(message, action)}
|
||||||
|
disabled={sending}
|
||||||
|
className="mt-2 w-full rounded-lg bg-indigo-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${message.id}-${action.type}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitCartAction(message, action)}
|
||||||
|
disabled={sending}
|
||||||
|
className="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-left text-xs font-bold text-indigo-700 hover:bg-indigo-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
<span className="block truncate text-[11px] font-medium text-slate-500">
|
||||||
|
{action.product.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Require shop context; otherwise render nothing.
|
// Require shop context; otherwise render nothing.
|
||||||
if (!shop) {
|
if (!shop) {
|
||||||
return null
|
return null
|
||||||
@ -395,8 +556,9 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`}
|
className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`}
|
||||||
>
|
>
|
||||||
|
<div className="max-w-[88%]">
|
||||||
<div
|
<div
|
||||||
className={`max-w-[88%] px-3 py-2 rounded-2xl text-sm font-medium whitespace-pre-wrap ${
|
className={`px-3 py-2 rounded-2xl text-sm font-medium whitespace-pre-wrap ${
|
||||||
m.senderType === "AI"
|
m.senderType === "AI"
|
||||||
? "bg-white border border-slate-200 text-slate-800"
|
? "bg-white border border-slate-200 text-slate-800"
|
||||||
: "bg-indigo-600 text-white"
|
: "bg-indigo-600 text-white"
|
||||||
@ -404,6 +566,8 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
>
|
>
|
||||||
{renderMessageContent(m.content)}
|
{renderMessageContent(m.content)}
|
||||||
</div>
|
</div>
|
||||||
|
{renderMessageActions(m)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{aiTyping ? (
|
{aiTyping ? (
|
||||||
|
|||||||
@ -9,7 +9,18 @@ import {
|
|||||||
orderNeedsDisambiguation,
|
orderNeedsDisambiguation,
|
||||||
} from "@/lib/ai/tools/orderTools"
|
} from "@/lib/ai/tools/orderTools"
|
||||||
import type { OrderListCandidate } from "@/lib/ai/tools/orderTools"
|
import type { OrderListCandidate } from "@/lib/ai/tools/orderTools"
|
||||||
import { searchProductsForStore } from "@/lib/ai/tools/productTools"
|
import {
|
||||||
|
productSearchHitToActionProduct,
|
||||||
|
searchProductsForStore,
|
||||||
|
type ProductSearchHit,
|
||||||
|
} from "@/lib/ai/tools/productTools"
|
||||||
|
import {
|
||||||
|
buildAddToCartAction,
|
||||||
|
buildUpsellSuggestionAction,
|
||||||
|
type StorefrontActionProduct,
|
||||||
|
type StorefrontChatAction,
|
||||||
|
} from "@/lib/storefront-chat/actions"
|
||||||
|
import { findAutomaticUpsellCandidates } from "@/lib/storefront-chat/upsells"
|
||||||
import {
|
import {
|
||||||
isAutomationEnabled,
|
isAutomationEnabled,
|
||||||
isModuleEnabled,
|
isModuleEnabled,
|
||||||
@ -167,6 +178,8 @@ export type RunL1Input = {
|
|||||||
export type RunL1Result = {
|
export type RunL1Result = {
|
||||||
text: string
|
text: string
|
||||||
meta: L1RunMeta
|
meta: L1RunMeta
|
||||||
|
actions?: StorefrontChatAction[]
|
||||||
|
pendingCartProduct?: StorefrontActionProduct | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyToGroq(
|
function historyToGroq(
|
||||||
@ -701,18 +714,23 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let productFacts = ""
|
let productFacts = ""
|
||||||
|
let productHits: ProductSearchHit[] = []
|
||||||
const runProductSearch =
|
const runProductSearch =
|
||||||
(activeModule === "PRODUCT_FIT" &&
|
(activeModule === "PRODUCT_FIT" &&
|
||||||
isAutomationEnabled(modules, activeModule, "knowledge")) ||
|
isAutomationEnabled(modules, activeModule, "knowledge")) ||
|
||||||
(activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule))
|
(activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule)) ||
|
||||||
|
(input.channel === "widget" &&
|
||||||
|
retrieval.productCount > 0 &&
|
||||||
|
!ORDER_DETAIL_MODULES.includes(activeModule))
|
||||||
if (runProductSearch) {
|
if (runProductSearch) {
|
||||||
const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5)
|
const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5)
|
||||||
|
productHits = hits
|
||||||
usedTools.push("search_products")
|
usedTools.push("search_products")
|
||||||
if (hits.length) {
|
if (hits.length) {
|
||||||
productFacts = hits
|
productFacts = hits
|
||||||
.map(
|
.map(
|
||||||
(h) =>
|
(h) =>
|
||||||
`- ${h.title} (SKU ${h.sku ?? "n/a"}) | ${h.inventorySummary ?? "inventory n/a"}${h.metafieldsSummary ? ` | ${h.metafieldsSummary}` : ""}`,
|
`- ${h.title} (SKU ${h.sku ?? "n/a"}) | ${h.inventorySummary ?? "inventory n/a"}${h.price ? ` | Price ${h.price}` : ""}${h.requiresVariantSelection ? " | Cart: ask the customer which option/variant they want before adding to cart." : h.variantId ? " | Cart: this product can be added to cart if the customer wants it." : ""}${h.metafieldsSummary ? ` | ${h.metafieldsSummary}` : ""}`,
|
||||||
)
|
)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
@ -727,6 +745,27 @@ 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}` : "",
|
||||||
@ -753,6 +792,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
|
actions,
|
||||||
|
pendingCartProduct,
|
||||||
meta: {
|
meta: {
|
||||||
module: activeModule,
|
module: activeModule,
|
||||||
pillar,
|
pillar,
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export async function generateStorefrontReply(params: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
content: result.text,
|
content: result.text,
|
||||||
|
actions: result.actions ?? [],
|
||||||
|
pendingCartProduct: result.pendingCartProduct ?? null,
|
||||||
meta: {
|
meta: {
|
||||||
kbHits: result.meta.kbHits,
|
kbHits: result.meta.kbHits,
|
||||||
productHits: result.meta.productHits,
|
productHits: result.meta.productHits,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
||||||
|
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
||||||
|
|
||||||
export type ProductSearchHit = {
|
export type ProductSearchHit = {
|
||||||
shopifyProductId: string
|
shopifyProductId: string
|
||||||
@ -7,6 +9,15 @@ export type ProductSearchHit = {
|
|||||||
sku: string | null
|
sku: string | null
|
||||||
inventorySummary: string | null
|
inventorySummary: string | null
|
||||||
metafieldsSummary: string | null
|
metafieldsSummary: string | null
|
||||||
|
productType: string | null
|
||||||
|
status: string | null
|
||||||
|
variantId: string | null
|
||||||
|
variantTitle: string | null
|
||||||
|
price: string | null
|
||||||
|
inventoryQuantity: number | null
|
||||||
|
imageUrl: string | null
|
||||||
|
tokens: string[]
|
||||||
|
requiresVariantSelection: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function metafieldsToSummary(metafields: unknown): string | null {
|
function metafieldsToSummary(metafields: unknown): string | null {
|
||||||
@ -41,6 +52,49 @@ function inventoryFromVariants(variants: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageUrlFromImages(images: unknown): string | null {
|
||||||
|
const rows = typeof images === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(images) as unknown
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: images
|
||||||
|
if (!Array.isArray(rows)) return null
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== "object") continue
|
||||||
|
const image = row as { src?: unknown; url?: unknown }
|
||||||
|
const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : ""
|
||||||
|
if (src.trim()) return src.trim()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function termsFromText(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/g)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productSearchHitToActionProduct(hit: ProductSearchHit): StorefrontActionProduct | null {
|
||||||
|
if (!hit.variantId) return null
|
||||||
|
return {
|
||||||
|
productId: hit.shopifyProductId,
|
||||||
|
title: hit.title,
|
||||||
|
variantId: hit.variantId,
|
||||||
|
variantTitle: hit.variantTitle,
|
||||||
|
sku: hit.sku,
|
||||||
|
price: hit.price,
|
||||||
|
imageUrl: hit.imageUrl,
|
||||||
|
productType: hit.productType,
|
||||||
|
quantity: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyword search by title / SKU across cached products (MVP; no full-text index).
|
* Keyword search by title / SKU across cached products (MVP; no full-text index).
|
||||||
*/
|
*/
|
||||||
@ -49,12 +103,7 @@ export async function searchProductsForStore(
|
|||||||
query: string,
|
query: string,
|
||||||
limit = 8,
|
limit = 8,
|
||||||
): Promise<ProductSearchHit[]> {
|
): Promise<ProductSearchHit[]> {
|
||||||
const terms = query
|
const terms = termsFromText(query).slice(0, 6)
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^a-z0-9]+/g)
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t.length >= 2)
|
|
||||||
.slice(0, 6)
|
|
||||||
|
|
||||||
if (!terms.length) return []
|
if (!terms.length) return []
|
||||||
|
|
||||||
@ -66,14 +115,17 @@ export async function searchProductsForStore(
|
|||||||
select: {
|
select: {
|
||||||
shopifyProductId: true,
|
shopifyProductId: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
productType: true,
|
||||||
|
status: true,
|
||||||
variants: true,
|
variants: true,
|
||||||
|
images: true,
|
||||||
metafields: true,
|
metafields: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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} ${JSON.stringify(p.variants)}`.toLowerCase()
|
const hay = `${p.title} ${p.productType ?? ""} ${JSON.stringify(p.variants)} ${metafieldsToSummary(p.metafields) ?? ""}`.toLowerCase()
|
||||||
let score = 0
|
let score = 0
|
||||||
for (const t of terms) {
|
for (const t of terms) {
|
||||||
if (hay.includes(t)) score += 1
|
if (hay.includes(t)) score += 1
|
||||||
@ -83,21 +135,34 @@ export async function searchProductsForStore(
|
|||||||
scored.sort((a, b) => b.score - a.score)
|
scored.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
return scored.slice(0, limit).map(({ p }) => {
|
return scored.slice(0, limit).map(({ p }) => {
|
||||||
const variants = p.variants
|
const variants = extractCartableVariants(p.variants)
|
||||||
|
const selected = selectVariantForCart({ variants, message: query })
|
||||||
|
const selectedVariant = selected.status === "selected" ? selected.variant : null
|
||||||
|
const firstVariant = variants[0] ?? null
|
||||||
let sku: string | null = null
|
let sku: string | null = null
|
||||||
try {
|
if (selectedVariant?.sku) sku = selectedVariant.sku
|
||||||
const arr =
|
else if (firstVariant?.sku) sku = firstVariant.sku
|
||||||
typeof variants === "string" ? JSON.parse(variants) : variants
|
|
||||||
if (Array.isArray(arr) && arr[0]?.sku) sku = String(arr[0].sku)
|
const tokens = Array.from(new Set([
|
||||||
} catch {
|
...termsFromText(p.title),
|
||||||
/* ignore */
|
...termsFromText(p.productType ?? ""),
|
||||||
}
|
])).slice(0, 16)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shopifyProductId: p.shopifyProductId,
|
shopifyProductId: p.shopifyProductId,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
sku,
|
sku,
|
||||||
inventorySummary: inventoryFromVariants(p.variants),
|
inventorySummary: inventoryFromVariants(p.variants),
|
||||||
metafieldsSummary: metafieldsToSummary(p.metafields),
|
metafieldsSummary: metafieldsToSummary(p.metafields),
|
||||||
|
productType: p.productType,
|
||||||
|
status: p.status,
|
||||||
|
variantId: selectedVariant?.id ?? null,
|
||||||
|
variantTitle: selectedVariant?.title ?? null,
|
||||||
|
price: selectedVariant?.price ?? firstVariant?.price ?? null,
|
||||||
|
inventoryQuantity: selectedVariant?.inventoryQuantity ?? firstVariant?.inventoryQuantity ?? null,
|
||||||
|
imageUrl: imageUrlFromImages(p.images),
|
||||||
|
tokens,
|
||||||
|
requiresVariantSelection: selected.status === "needs_selection",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -39,17 +39,41 @@ const storefrontVisitorIdSchema = z
|
|||||||
.min(1, "Missing visitorId")
|
.min(1, "Missing visitorId")
|
||||||
.max(300, "visitorId too long")
|
.max(300, "visitorId too long")
|
||||||
|
|
||||||
|
export const storefrontChatActionSchema = z.object({
|
||||||
|
type: z.enum(["add_to_cart", "upsell_suggestion"]),
|
||||||
|
productId: z.string().trim().min(1, "Missing productId").max(128),
|
||||||
|
variantId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Missing variantId")
|
||||||
|
.max(128)
|
||||||
|
.optional(),
|
||||||
|
quantity: z.coerce.number().int().min(1).max(10).default(1),
|
||||||
|
sourceMessageId: z.string().trim().max(128).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
/** Anonymous storefront widget message */
|
/** Anonymous storefront widget message */
|
||||||
export const storefrontChatPostSchema = z.object({
|
export const storefrontChatPostSchema = z
|
||||||
|
.object({
|
||||||
shop: storefrontShopSchema,
|
shop: storefrontShopSchema,
|
||||||
visitorId: storefrontVisitorIdSchema,
|
visitorId: storefrontVisitorIdSchema,
|
||||||
content: z
|
content: z
|
||||||
.string()
|
.string()
|
||||||
.max(2000, "Message too long")
|
.max(2000, "Message too long")
|
||||||
.transform((s) => s.trim())
|
.optional()
|
||||||
.refine((s) => s.length >= 1, "Empty message"),
|
.default("")
|
||||||
|
.transform((s) => s.trim()),
|
||||||
customerEmail: optionalEmailField(),
|
customerEmail: optionalEmailField(),
|
||||||
})
|
action: storefrontChatActionSchema.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.content || data.action) return
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["content"],
|
||||||
|
message: "Empty message",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export const CHAT_MESSAGE_CONTENT_MAX = 16_000
|
export const CHAT_MESSAGE_CONTENT_MAX = 16_000
|
||||||
|
|
||||||
@ -124,6 +148,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 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>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"
|
|||||||
import {
|
import {
|
||||||
newsletterSubscribeSchema,
|
newsletterSubscribeSchema,
|
||||||
storefrontChatPostSchema,
|
storefrontChatPostSchema,
|
||||||
|
storefrontChatActionSchema,
|
||||||
chatMessagePostSchema,
|
chatMessagePostSchema,
|
||||||
CHAT_MESSAGE_CONTENT_MAX,
|
CHAT_MESSAGE_CONTENT_MAX,
|
||||||
CHAT_ATTACHMENT_JSON_MAX,
|
CHAT_ATTACHMENT_JSON_MAX,
|
||||||
@ -45,6 +46,33 @@ describe("storefrontChatPostSchema", () => {
|
|||||||
}).success,
|
}).success,
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("accepts action-only add-to-cart payloads", () => {
|
||||||
|
const result = storefrontChatPostSchema.safeParse({
|
||||||
|
shop: "x.myshopify.com",
|
||||||
|
visitorId: "v1",
|
||||||
|
action: {
|
||||||
|
type: "add_to_cart",
|
||||||
|
productId: "123",
|
||||||
|
variantId: "456",
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) expect(result.data.content).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("storefrontChatActionSchema", () => {
|
||||||
|
it("bounds action quantity", () => {
|
||||||
|
expect(
|
||||||
|
storefrontChatActionSchema.safeParse({
|
||||||
|
type: "add_to_cart",
|
||||||
|
productId: "123",
|
||||||
|
quantity: 11,
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("chatMessagePostSchema", () => {
|
describe("chatMessagePostSchema", () => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export const storeSelectStorefrontChatApi = {
|
|||||||
id: true,
|
id: true,
|
||||||
storefrontChatEnabled: true,
|
storefrontChatEnabled: true,
|
||||||
businessName: true,
|
businessName: true,
|
||||||
|
shopifyDomain: true,
|
||||||
aiModules: true,
|
aiModules: true,
|
||||||
categoryMetadetailsEnabled: true,
|
categoryMetadetailsEnabled: true,
|
||||||
sharedFieldAnswers: true,
|
sharedFieldAnswers: true,
|
||||||
@ -83,4 +84,5 @@ export const storeSelectStorefrontConversationSession = {
|
|||||||
emailVerifiedAt: true,
|
emailVerifiedAt: true,
|
||||||
sessionExpiresAt: true,
|
sessionExpiresAt: true,
|
||||||
boundShopifyOrderId: true,
|
boundShopifyOrderId: true,
|
||||||
|
l1State: true,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
147
src/lib/storefront-chat/actions.ts
Normal file
147
src/lib/storefront-chat/actions.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
export type StorefrontActionProduct = {
|
||||||
|
productId: string
|
||||||
|
title: string
|
||||||
|
variantId?: string
|
||||||
|
variantTitle?: string | null
|
||||||
|
sku?: string | null
|
||||||
|
price?: string | null
|
||||||
|
imageUrl?: string | null
|
||||||
|
productType?: string | null
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorefrontAddToCartAction = {
|
||||||
|
type: "add_to_cart"
|
||||||
|
label: string
|
||||||
|
source: "primary" | "upsell"
|
||||||
|
product: StorefrontActionProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorefrontUpsellSuggestionAction = {
|
||||||
|
type: "upsell_suggestion"
|
||||||
|
label: string
|
||||||
|
message: string
|
||||||
|
product: StorefrontActionProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorefrontOpenCartAction = {
|
||||||
|
type: "open_cart"
|
||||||
|
label: string
|
||||||
|
cartUrl: string
|
||||||
|
product?: StorefrontActionProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorefrontChatAction =
|
||||||
|
| StorefrontAddToCartAction
|
||||||
|
| StorefrontUpsellSuggestionAction
|
||||||
|
| StorefrontOpenCartAction
|
||||||
|
|
||||||
|
function clampQuantity(quantity: unknown): number {
|
||||||
|
const n = Number(quantity)
|
||||||
|
if (!Number.isFinite(n)) return 1
|
||||||
|
return Math.max(1, Math.min(10, Math.trunc(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeActionProduct(raw: unknown): StorefrontActionProduct | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null
|
||||||
|
const product = raw as Record<string, unknown>
|
||||||
|
const productId = typeof product.productId === "string" ? product.productId.trim() : ""
|
||||||
|
const title = typeof product.title === "string" ? product.title.trim() : ""
|
||||||
|
if (!productId || !title) return null
|
||||||
|
|
||||||
|
const variantId = typeof product.variantId === "string" ? product.variantId.trim() : ""
|
||||||
|
const variantTitle = typeof product.variantTitle === "string" ? product.variantTitle.trim() : null
|
||||||
|
const sku = typeof product.sku === "string" ? product.sku.trim() : null
|
||||||
|
const price = typeof product.price === "string" ? product.price.trim() : null
|
||||||
|
const imageUrl = typeof product.imageUrl === "string" ? product.imageUrl.trim() : null
|
||||||
|
const productType = typeof product.productType === "string" ? product.productType.trim() : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
title,
|
||||||
|
...(variantId ? { variantId } : {}),
|
||||||
|
variantTitle,
|
||||||
|
sku,
|
||||||
|
price,
|
||||||
|
imageUrl,
|
||||||
|
productType,
|
||||||
|
quantity: clampQuantity(product.quantity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStorefrontChatActions(raw: unknown): StorefrontChatAction[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const actions: StorefrontChatAction[] = []
|
||||||
|
|
||||||
|
for (const item of raw.slice(0, 6)) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue
|
||||||
|
const action = item as Record<string, unknown>
|
||||||
|
const type = action.type
|
||||||
|
const label = typeof action.label === "string" ? action.label.trim() : ""
|
||||||
|
|
||||||
|
if (type === "open_cart") {
|
||||||
|
const cartUrl = typeof action.cartUrl === "string" ? action.cartUrl.trim() : ""
|
||||||
|
if (!label || !/^https?:\/\//i.test(cartUrl)) continue
|
||||||
|
actions.push({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
cartUrl,
|
||||||
|
product: normalizeActionProduct(action.product) ?? undefined,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = normalizeActionProduct(action.product)
|
||||||
|
if (!product || !label) continue
|
||||||
|
|
||||||
|
if (type === "add_to_cart") {
|
||||||
|
const source = action.source === "upsell" ? "upsell" : "primary"
|
||||||
|
actions.push({ type, label, source, product })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "upsell_suggestion") {
|
||||||
|
const message = typeof action.message === "string" ? action.message.trim() : ""
|
||||||
|
if (!message) continue
|
||||||
|
actions.push({ type, label, message, product })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAddToCartAction(
|
||||||
|
product: StorefrontActionProduct,
|
||||||
|
source: "primary" | "upsell" = "primary",
|
||||||
|
): StorefrontAddToCartAction {
|
||||||
|
return {
|
||||||
|
type: "add_to_cart",
|
||||||
|
label: source === "upsell" ? "Add this too" : "Add to cart",
|
||||||
|
source,
|
||||||
|
product: { ...product, quantity: clampQuantity(product.quantity) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUpsellSuggestionAction(
|
||||||
|
product: StorefrontActionProduct,
|
||||||
|
baseTitle: string,
|
||||||
|
): StorefrontUpsellSuggestionAction {
|
||||||
|
return {
|
||||||
|
type: "upsell_suggestion",
|
||||||
|
label: "Add suggested item",
|
||||||
|
message: `Would you like to add ${product.title} with ${baseTitle}?`,
|
||||||
|
product: { ...product, quantity: clampQuantity(product.quantity) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenCartAction(
|
||||||
|
cartUrl: string,
|
||||||
|
product?: StorefrontActionProduct,
|
||||||
|
): StorefrontOpenCartAction {
|
||||||
|
return {
|
||||||
|
type: "open_cart",
|
||||||
|
label: "Open cart",
|
||||||
|
cartUrl,
|
||||||
|
product: product ? { ...product, quantity: clampQuantity(product.quantity) } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/lib/storefront-chat/cartLinks.test.ts
Normal file
57
src/lib/storefront-chat/cartLinks.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
buildCartPermalink,
|
||||||
|
extractCartableVariants,
|
||||||
|
selectVariantForCart,
|
||||||
|
} from "./cartLinks"
|
||||||
|
|
||||||
|
describe("storefront cart links", () => {
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
id: 111,
|
||||||
|
title: "Default Title",
|
||||||
|
sku: "SHOE-1",
|
||||||
|
price: "25.00",
|
||||||
|
inventory_quantity: 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it("selects a single cartable variant", () => {
|
||||||
|
const result = selectVariantForCart({
|
||||||
|
variants: extractCartableVariants(variants),
|
||||||
|
message: "add it",
|
||||||
|
})
|
||||||
|
expect(result.status).toBe("selected")
|
||||||
|
if (result.status === "selected") {
|
||||||
|
expect(result.variant.id).toBe("111")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("asks for a variant when multiple meaningful options exist", () => {
|
||||||
|
const result = selectVariantForCart({
|
||||||
|
variants: extractCartableVariants([
|
||||||
|
{ id: 1, title: "Black / 8", option1: "Black", option2: "8", inventory_quantity: 2 },
|
||||||
|
{ id: 2, title: "Black / 9", option1: "Black", option2: "9", inventory_quantity: 2 },
|
||||||
|
]),
|
||||||
|
message: "yes",
|
||||||
|
})
|
||||||
|
expect(result.status).toBe("needs_selection")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects requested variants that are not in the cached product", () => {
|
||||||
|
const result = selectVariantForCart({
|
||||||
|
variants: extractCartableVariants(variants),
|
||||||
|
requestedVariantId: "999",
|
||||||
|
})
|
||||||
|
expect(result.status).toBe("invalid_variant")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds Shopify cart permalinks with normalized domains and bounded quantities", () => {
|
||||||
|
expect(
|
||||||
|
buildCartPermalink("https://demo.myshopify.com/", [
|
||||||
|
{ variantId: "111", quantity: 2 },
|
||||||
|
{ variantId: "222", quantity: 25 },
|
||||||
|
]),
|
||||||
|
).toBe("https://demo.myshopify.com/cart/111:2,222:10")
|
||||||
|
})
|
||||||
|
})
|
||||||
169
src/lib/storefront-chat/cartLinks.ts
Normal file
169
src/lib/storefront-chat/cartLinks.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
export type CartableVariant = {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
sku: string | null
|
||||||
|
price: string | null
|
||||||
|
inventoryQuantity: number | null
|
||||||
|
optionValues: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariantSelectionResult =
|
||||||
|
| { status: "selected"; variant: CartableVariant }
|
||||||
|
| { status: "needs_selection"; variants: CartableVariant[] }
|
||||||
|
| { status: "invalid_variant" }
|
||||||
|
| { status: "unavailable"; variant?: CartableVariant }
|
||||||
|
|
||||||
|
export function normalizeShopDomain(input: string) {
|
||||||
|
return input.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanString(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
const text = String(value).trim()
|
||||||
|
return text ? text : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanInventory(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === "") return null
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefaultVariantTitle(title: string | null) {
|
||||||
|
return !title || /^default title$/i.test(title.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionValuesFromVariant(variant: Record<string, unknown>, title: string | null) {
|
||||||
|
const values = [variant.option1, variant.option2, variant.option3]
|
||||||
|
.map(cleanString)
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
|
||||||
|
if (values.length > 0) return values
|
||||||
|
return isDefaultVariantTitle(title) || !title ? [] : [title]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCartableVariants(rawVariants: unknown): CartableVariant[] {
|
||||||
|
const variants =
|
||||||
|
typeof rawVariants === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawVariants) as unknown
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: rawVariants
|
||||||
|
|
||||||
|
if (!Array.isArray(variants)) return []
|
||||||
|
|
||||||
|
return variants
|
||||||
|
.map((variant): CartableVariant | null => {
|
||||||
|
if (!variant || typeof variant !== "object" || Array.isArray(variant)) return null
|
||||||
|
const v = variant as Record<string, unknown>
|
||||||
|
const id = cleanString(v.id)
|
||||||
|
if (!id) return null
|
||||||
|
const title = cleanString(v.title)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
sku: cleanString(v.sku),
|
||||||
|
price: cleanString(v.price),
|
||||||
|
inventoryQuantity: cleanInventory(v.inventory_quantity),
|
||||||
|
optionValues: optionValuesFromVariant(v, title),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((variant): variant is CartableVariant => Boolean(variant))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownUnavailable(variant: CartableVariant) {
|
||||||
|
return variant.inventoryQuantity !== null && variant.inventoryQuantity <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreVariantByMessage(variant: CartableVariant, message: string) {
|
||||||
|
const haystack = [
|
||||||
|
variant.title,
|
||||||
|
variant.sku,
|
||||||
|
...variant.optionValues,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
const words = message
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/g)
|
||||||
|
.map((word) => word.trim())
|
||||||
|
.filter((word) => word.length >= 2)
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
for (const word of words) {
|
||||||
|
if (haystack.includes(word)) score += 1
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectVariantForCart(params: {
|
||||||
|
variants: CartableVariant[]
|
||||||
|
requestedVariantId?: string | null
|
||||||
|
message?: string | null
|
||||||
|
}): VariantSelectionResult {
|
||||||
|
const { variants, requestedVariantId, message } = params
|
||||||
|
if (variants.length === 0) return { status: "unavailable" }
|
||||||
|
|
||||||
|
if (requestedVariantId?.trim()) {
|
||||||
|
const variant = variants.find((v) => v.id === requestedVariantId.trim())
|
||||||
|
if (!variant) return { status: "invalid_variant" }
|
||||||
|
if (isKnownUnavailable(variant)) return { status: "unavailable", variant }
|
||||||
|
return { status: "selected", variant }
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableVariants = variants.filter((variant) => !isKnownUnavailable(variant))
|
||||||
|
if (availableVariants.length === 0) {
|
||||||
|
return { status: "unavailable", variant: variants[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableVariants.length === 1) {
|
||||||
|
return { status: "selected", variant: availableVariants[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDefaultTitles = availableVariants.every((variant) => isDefaultVariantTitle(variant.title))
|
||||||
|
if (allDefaultTitles) {
|
||||||
|
return { status: "selected", variant: availableVariants[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanMessage = message?.trim() ?? ""
|
||||||
|
if (cleanMessage) {
|
||||||
|
const scored = availableVariants
|
||||||
|
.map((variant) => ({ variant, score: scoreVariantByMessage(variant, cleanMessage) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
if (scored[0]?.score && scored[0].score > (scored[1]?.score ?? 0)) {
|
||||||
|
return { status: "selected", variant: scored[0].variant }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "needs_selection", variants: availableVariants.slice(0, 8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCartPermalink(
|
||||||
|
shopifyDomain: string,
|
||||||
|
lines: Array<{ variantId: string; quantity?: number }>,
|
||||||
|
) {
|
||||||
|
const cleanDomain = normalizeShopDomain(shopifyDomain)
|
||||||
|
const encodedLines = lines
|
||||||
|
.map((line) => {
|
||||||
|
const variantId = line.variantId.trim()
|
||||||
|
if (!variantId) return null
|
||||||
|
const quantity = Math.max(1, Math.min(10, Math.trunc(Number(line.quantity) || 1)))
|
||||||
|
return `${encodeURIComponent(variantId)}:${quantity}`
|
||||||
|
})
|
||||||
|
.filter((line): line is string => Boolean(line))
|
||||||
|
|
||||||
|
if (!cleanDomain || encodedLines.length === 0) return null
|
||||||
|
return `https://${cleanDomain}/cart/${encodedLines.join(",")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVariantChoices(variants: CartableVariant[]) {
|
||||||
|
return variants
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((variant) => variant.optionValues.join(" / ") || variant.title || variant.sku || variant.id)
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
43
src/lib/storefront-chat/upsells.test.ts
Normal file
43
src/lib/storefront-chat/upsells.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { rankAutomaticUpsellCandidates } from "./upsells"
|
||||||
|
|
||||||
|
describe("automatic storefront upsells", () => {
|
||||||
|
it("suggests school socks for school shoes and excludes the base product", () => {
|
||||||
|
const results = rankAutomaticUpsellCandidates({
|
||||||
|
baseProduct: {
|
||||||
|
productId: "shoe-1",
|
||||||
|
title: "School Shoes",
|
||||||
|
variantId: "v-shoe",
|
||||||
|
productType: "Shoes",
|
||||||
|
},
|
||||||
|
query: "Do you have school shoes?",
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
shopifyProductId: "shoe-1",
|
||||||
|
title: "School Shoes",
|
||||||
|
productType: "Shoes",
|
||||||
|
status: "active",
|
||||||
|
variants: [{ id: "v-shoe", title: "Default Title", inventory_quantity: 5 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shopifyProductId: "sock-1",
|
||||||
|
title: "School Socks",
|
||||||
|
productType: "Accessories",
|
||||||
|
status: "active",
|
||||||
|
variants: [{ id: "v-sock", title: "Default Title", inventory_quantity: 8, price: "5.00" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shopifyProductId: "bag-1",
|
||||||
|
title: "Travel Bag",
|
||||||
|
productType: "Bags",
|
||||||
|
status: "active",
|
||||||
|
variants: [{ id: "v-bag", title: "Default Title", inventory_quantity: 8 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0].productId).toBe("sock-1")
|
||||||
|
expect(results[0].variantId).toBe("v-sock")
|
||||||
|
})
|
||||||
|
})
|
||||||
167
src/lib/storefront-chat/upsells.ts
Normal file
167
src/lib/storefront-chat/upsells.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
||||||
|
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
||||||
|
|
||||||
|
type CachedProductLike = {
|
||||||
|
shopifyProductId: string
|
||||||
|
title: string
|
||||||
|
productType?: string | null
|
||||||
|
status?: string | null
|
||||||
|
variants?: unknown
|
||||||
|
images?: unknown
|
||||||
|
metafields?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set([
|
||||||
|
"the",
|
||||||
|
"and",
|
||||||
|
"for",
|
||||||
|
"with",
|
||||||
|
"that",
|
||||||
|
"this",
|
||||||
|
"want",
|
||||||
|
"need",
|
||||||
|
"product",
|
||||||
|
"item",
|
||||||
|
"buy",
|
||||||
|
"cart",
|
||||||
|
"add",
|
||||||
|
])
|
||||||
|
|
||||||
|
const ACCESSORY_PAIRS: Array<{ base: RegExp; accessory: RegExp; score: number }> = [
|
||||||
|
{ base: /\bshoe|shoes|sneaker|sneakers|boot|boots\b/i, accessory: /\bsock|socks|laces?|insole|insoles\b/i, score: 8 },
|
||||||
|
{ base: /\bshirt|uniform|dress\b/i, accessory: /\btie|belt|cap|scarf\b/i, score: 5 },
|
||||||
|
{ base: /\bbag|backpack\b/i, accessory: /\bbottle|lunch|pouch|case\b/i, score: 5 },
|
||||||
|
{ base: /\bphone|mobile\b/i, accessory: /\bcase|cover|charger|cable\b/i, score: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function tokenize(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/g)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !STOPWORDS.has(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTokens(...texts: Array<string | null | undefined>) {
|
||||||
|
return Array.from(new Set(texts.flatMap((text) => tokenize(text ?? ""))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrlFromImages(images: unknown): string | null {
|
||||||
|
const rows = typeof images === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(images) as unknown
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: images
|
||||||
|
if (!Array.isArray(rows)) return null
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== "object") continue
|
||||||
|
const image = row as { src?: unknown; url?: unknown }
|
||||||
|
const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : ""
|
||||||
|
if (src.trim()) return src.trim()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function productText(product: CachedProductLike) {
|
||||||
|
return [
|
||||||
|
product.title,
|
||||||
|
product.productType,
|
||||||
|
JSON.stringify(product.metafields ?? ""),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessoryScore(baseText: string, candidateText: string) {
|
||||||
|
let score = 0
|
||||||
|
for (const pair of ACCESSORY_PAIRS) {
|
||||||
|
if (pair.base.test(baseText) && pair.accessory.test(candidateText)) {
|
||||||
|
score += pair.score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUpsellActionProduct(product: CachedProductLike, query: string): StorefrontActionProduct | null {
|
||||||
|
const variants = extractCartableVariants(product.variants)
|
||||||
|
const selected = selectVariantForCart({ variants, message: query })
|
||||||
|
if (selected.status !== "selected") return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId: product.shopifyProductId,
|
||||||
|
title: product.title,
|
||||||
|
variantId: selected.variant.id,
|
||||||
|
variantTitle: selected.variant.title,
|
||||||
|
sku: selected.variant.sku,
|
||||||
|
price: selected.variant.price,
|
||||||
|
imageUrl: imageUrlFromImages(product.images),
|
||||||
|
productType: product.productType ?? null,
|
||||||
|
quantity: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankAutomaticUpsellCandidates(params: {
|
||||||
|
baseProduct: StorefrontActionProduct
|
||||||
|
query: string
|
||||||
|
candidates: CachedProductLike[]
|
||||||
|
limit?: number
|
||||||
|
}): StorefrontActionProduct[] {
|
||||||
|
const { baseProduct, query, candidates, limit = 1 } = params
|
||||||
|
const baseText = [baseProduct.title, baseProduct.productType, query].filter(Boolean).join(" ")
|
||||||
|
const baseTokens = new Set(uniqueTokens(baseText))
|
||||||
|
|
||||||
|
const ranked = candidates
|
||||||
|
.filter((candidate) => candidate.shopifyProductId !== baseProduct.productId)
|
||||||
|
.filter((candidate) => !candidate.status || candidate.status.toLowerCase() === "active")
|
||||||
|
.map((candidate) => {
|
||||||
|
const candidateText = productText(candidate)
|
||||||
|
const candidateTokens = uniqueTokens(candidateText)
|
||||||
|
let score = accessoryScore(baseText, candidateText)
|
||||||
|
for (const token of candidateTokens) {
|
||||||
|
if (baseTokens.has(token)) score += 2
|
||||||
|
}
|
||||||
|
if (candidate.productType && baseProduct.productType && candidate.productType !== baseProduct.productType) {
|
||||||
|
score += 1
|
||||||
|
}
|
||||||
|
const product = toUpsellActionProduct(candidate, query)
|
||||||
|
return { product, score }
|
||||||
|
})
|
||||||
|
.filter((row): row is { product: StorefrontActionProduct; score: number } => Boolean(row.product) && row.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
|
return ranked.slice(0, limit).map((row) => row.product)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findAutomaticUpsellCandidates(params: {
|
||||||
|
storeId: string
|
||||||
|
baseProduct: StorefrontActionProduct
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
}): Promise<StorefrontActionProduct[]> {
|
||||||
|
const products = await prisma.cachedProduct.findMany({
|
||||||
|
where: { storeId: params.storeId },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 300,
|
||||||
|
select: {
|
||||||
|
shopifyProductId: true,
|
||||||
|
title: true,
|
||||||
|
productType: true,
|
||||||
|
status: true,
|
||||||
|
variants: true,
|
||||||
|
images: true,
|
||||||
|
metafields: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return rankAutomaticUpsellCandidates({
|
||||||
|
baseProduct: params.baseProduct,
|
||||||
|
query: params.query,
|
||||||
|
candidates: products,
|
||||||
|
limit: params.limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user