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
|
||||
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)
|
||||
senderType StorefrontChatSenderType
|
||||
content String @db.Text
|
||||
actions Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { Prisma } from "@prisma/client"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import {
|
||||
storeSelectStorefrontChatApi,
|
||||
@ -7,9 +8,23 @@ import {
|
||||
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
||||
import type { StorefrontChatActionInput } from "@/lib/api-schemas/public"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
import { extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
||||
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) {
|
||||
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) {
|
||||
try {
|
||||
const rate = await applyRateLimit(req, "public:storefront-chat", {
|
||||
@ -90,7 +295,8 @@ export async function POST(req: Request) {
|
||||
if (!parsed.success) {
|
||||
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)
|
||||
|
||||
@ -143,11 +349,65 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
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({
|
||||
where: { conversationId: conversation.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@ -228,16 +488,25 @@ export async function POST(req: Request) {
|
||||
: alreadyEscalated
|
||||
? ESCALATION_ALREADY_DISPATCHED_REPLY
|
||||
: ai.content
|
||||
const aiActions = aiContent === ai.content ? ai.actions ?? [] : []
|
||||
|
||||
const aiMessage = await prisma.storefrontChatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
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 (
|
||||
isEscalation &&
|
||||
!alreadyEscalated &&
|
||||
@ -256,7 +525,7 @@ export async function POST(req: Request) {
|
||||
storeId: store.id,
|
||||
to: ticketTo,
|
||||
subject: ticketSubject,
|
||||
bodyPreview: trimmedContent.slice(0, 2000),
|
||||
bodyPreview: customerContent.slice(0, 2000),
|
||||
isAutomated: true,
|
||||
trigger: STOREFRONT_TICKET_TRIGGER,
|
||||
status: "SENT",
|
||||
@ -322,7 +591,7 @@ export async function GET(req: Request) {
|
||||
where: { conversationId: conversation.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
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 })
|
||||
|
||||
@ -9,6 +9,47 @@ type WidgetMessage = {
|
||||
content: string
|
||||
createdAt: string
|
||||
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) {
|
||||
@ -63,6 +104,48 @@ function renderMessageContent(content: string) {
|
||||
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) {
|
||||
const key = `juno_storefront_visitor_id_${shop}`
|
||||
try {
|
||||
@ -167,22 +250,15 @@ export default function StorefrontChatWidgetPage() {
|
||||
)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
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) {
|
||||
setMessages(
|
||||
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())
|
||||
}))
|
||||
)
|
||||
setMessages(nextMessages.map((m) => toWidgetMessage(m)))
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) {
|
||||
setSendError(e?.message || "Failed to load chat history")
|
||||
setSendError(e instanceof Error ? e.message : "Failed to load chat history")
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@ -299,10 +375,10 @@ export default function StorefrontChatWidgetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const send = async (postAction?: WidgetPostAction, actionLabel?: string) => {
|
||||
if (!shop || !visitorId) return
|
||||
const text = composer.trim()
|
||||
if (!text) return
|
||||
const text = postAction ? actionLabel || "Add to cart" : composer.trim()
|
||||
if (!text && !postAction) return
|
||||
const typingStartedAt = Date.now()
|
||||
|
||||
const optimisticCustomerMessage: WidgetMessage = {
|
||||
@ -313,7 +389,9 @@ export default function StorefrontChatWidgetPage() {
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, optimisticCustomerMessage])
|
||||
if (!postAction) {
|
||||
setComposer("")
|
||||
}
|
||||
setAiTyping(true)
|
||||
setTimeout(() => composerRef.current?.focus(), 0)
|
||||
|
||||
@ -323,22 +401,17 @@ export default function StorefrontChatWidgetPage() {
|
||||
const res = await fetch("/api/storefront-chat/messages", {
|
||||
method: "POST",
|
||||
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(() => ({}))
|
||||
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) {
|
||||
const returnedTyped = returned.map((m: any) => ({
|
||||
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 returnedTyped = returned.map((m) => toWidgetMessage(m))
|
||||
const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI")
|
||||
const elapsed = Date.now() - typingStartedAt
|
||||
const minTypingMs = 1000
|
||||
@ -347,8 +420,8 @@ export default function StorefrontChatWidgetPage() {
|
||||
}
|
||||
setMessages(prev => [...prev, ...aiReplies])
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSendError(e?.message || "Failed to send message")
|
||||
} catch (e: unknown) {
|
||||
setSendError(e instanceof Error ? e.message : "Failed to send message")
|
||||
setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id))
|
||||
} finally {
|
||||
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.
|
||||
if (!shop) {
|
||||
return null
|
||||
@ -395,8 +556,9 @@ export default function StorefrontChatWidgetPage() {
|
||||
key={m.id}
|
||||
className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`}
|
||||
>
|
||||
<div className="max-w-[88%]">
|
||||
<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"
|
||||
? "bg-white border border-slate-200 text-slate-800"
|
||||
: "bg-indigo-600 text-white"
|
||||
@ -404,6 +566,8 @@ export default function StorefrontChatWidgetPage() {
|
||||
>
|
||||
{renderMessageContent(m.content)}
|
||||
</div>
|
||||
{renderMessageActions(m)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{aiTyping ? (
|
||||
|
||||
@ -9,7 +9,18 @@ import {
|
||||
orderNeedsDisambiguation,
|
||||
} 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 {
|
||||
isAutomationEnabled,
|
||||
isModuleEnabled,
|
||||
@ -167,6 +178,8 @@ export type RunL1Input = {
|
||||
export type RunL1Result = {
|
||||
text: string
|
||||
meta: L1RunMeta
|
||||
actions?: StorefrontChatAction[]
|
||||
pendingCartProduct?: StorefrontActionProduct | null
|
||||
}
|
||||
|
||||
function historyToGroq(
|
||||
@ -701,18 +714,23 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
||||
}
|
||||
|
||||
let productFacts = ""
|
||||
let productHits: ProductSearchHit[] = []
|
||||
const runProductSearch =
|
||||
(activeModule === "PRODUCT_FIT" &&
|
||||
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) {
|
||||
const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5)
|
||||
productHits = hits
|
||||
usedTools.push("search_products")
|
||||
if (hits.length) {
|
||||
productFacts = hits
|
||||
.map(
|
||||
(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")
|
||||
}
|
||||
@ -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 = [
|
||||
type1Facts,
|
||||
kbContext ? `Knowledge:\n${kbContext}` : "",
|
||||
@ -753,6 +792,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
||||
|
||||
return {
|
||||
text,
|
||||
actions,
|
||||
pendingCartProduct,
|
||||
meta: {
|
||||
module: activeModule,
|
||||
pillar,
|
||||
|
||||
@ -63,6 +63,8 @@ export async function generateStorefrontReply(params: {
|
||||
|
||||
return {
|
||||
content: result.text,
|
||||
actions: result.actions ?? [],
|
||||
pendingCartProduct: result.pendingCartProduct ?? null,
|
||||
meta: {
|
||||
kbHits: result.meta.kbHits,
|
||||
productHits: result.meta.productHits,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
||||
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
||||
|
||||
export type ProductSearchHit = {
|
||||
shopifyProductId: string
|
||||
@ -7,6 +9,15 @@ export type ProductSearchHit = {
|
||||
sku: string | null
|
||||
inventorySummary: 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 {
|
||||
@ -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).
|
||||
*/
|
||||
@ -49,12 +103,7 @@ export async function searchProductsForStore(
|
||||
query: string,
|
||||
limit = 8,
|
||||
): Promise<ProductSearchHit[]> {
|
||||
const terms = query
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 2)
|
||||
.slice(0, 6)
|
||||
const terms = termsFromText(query).slice(0, 6)
|
||||
|
||||
if (!terms.length) return []
|
||||
|
||||
@ -66,14 +115,17 @@ export async function searchProductsForStore(
|
||||
select: {
|
||||
shopifyProductId: true,
|
||||
title: true,
|
||||
productType: true,
|
||||
status: true,
|
||||
variants: true,
|
||||
images: true,
|
||||
metafields: true,
|
||||
},
|
||||
})
|
||||
|
||||
const scored: { p: (typeof products)[0]; score: number }[] = []
|
||||
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
|
||||
for (const t of terms) {
|
||||
if (hay.includes(t)) score += 1
|
||||
@ -83,21 +135,34 @@ export async function searchProductsForStore(
|
||||
scored.sort((a, b) => b.score - a.score)
|
||||
|
||||
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
|
||||
try {
|
||||
const arr =
|
||||
typeof variants === "string" ? JSON.parse(variants) : variants
|
||||
if (Array.isArray(arr) && arr[0]?.sku) sku = String(arr[0].sku)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (selectedVariant?.sku) sku = selectedVariant.sku
|
||||
else if (firstVariant?.sku) sku = firstVariant.sku
|
||||
|
||||
const tokens = Array.from(new Set([
|
||||
...termsFromText(p.title),
|
||||
...termsFromText(p.productType ?? ""),
|
||||
])).slice(0, 16)
|
||||
|
||||
return {
|
||||
shopifyProductId: p.shopifyProductId,
|
||||
title: p.title,
|
||||
sku,
|
||||
inventorySummary: inventoryFromVariants(p.variants),
|
||||
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) {
|
||||
|
||||
@ -39,17 +39,41 @@ const storefrontVisitorIdSchema = z
|
||||
.min(1, "Missing visitorId")
|
||||
.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 */
|
||||
export const storefrontChatPostSchema = z.object({
|
||||
export const storefrontChatPostSchema = z
|
||||
.object({
|
||||
shop: storefrontShopSchema,
|
||||
visitorId: storefrontVisitorIdSchema,
|
||||
content: z
|
||||
.string()
|
||||
.max(2000, "Message too long")
|
||||
.transform((s) => s.trim())
|
||||
.refine((s) => s.length >= 1, "Empty message"),
|
||||
.optional()
|
||||
.default("")
|
||||
.transform((s) => s.trim()),
|
||||
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
|
||||
|
||||
@ -124,6 +148,7 @@ export const storefrontOtpVerifySchema = z.object({
|
||||
|
||||
export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema>
|
||||
export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema>
|
||||
export type StorefrontChatActionInput = z.infer<typeof storefrontChatActionSchema>
|
||||
export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema>
|
||||
export type StorefrontOtpRequestInput = z.infer<typeof storefrontOtpRequestSchema>
|
||||
export type StorefrontOtpVerifyInput = z.infer<typeof storefrontOtpVerifySchema>
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
newsletterSubscribeSchema,
|
||||
storefrontChatPostSchema,
|
||||
storefrontChatActionSchema,
|
||||
chatMessagePostSchema,
|
||||
CHAT_MESSAGE_CONTENT_MAX,
|
||||
CHAT_ATTACHMENT_JSON_MAX,
|
||||
@ -45,6 +46,33 @@ describe("storefrontChatPostSchema", () => {
|
||||
}).success,
|
||||
).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", () => {
|
||||
|
||||
@ -11,6 +11,7 @@ export const storeSelectStorefrontChatApi = {
|
||||
id: true,
|
||||
storefrontChatEnabled: true,
|
||||
businessName: true,
|
||||
shopifyDomain: true,
|
||||
aiModules: true,
|
||||
categoryMetadetailsEnabled: true,
|
||||
sharedFieldAnswers: true,
|
||||
@ -83,4 +84,5 @@ export const storeSelectStorefrontConversationSession = {
|
||||
emailVerifiedAt: true,
|
||||
sessionExpiresAt: true,
|
||||
boundShopifyOrderId: true,
|
||||
l1State: true,
|
||||
} 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