Feature: upselling, add to cart modules

This commit is contained in:
root 2026-05-11 22:25:35 +05:00
parent 7ecdc917d6
commit 26fc46068f
16 changed files with 1251 additions and 69 deletions

1
.gitignore vendored
View File

@ -57,3 +57,4 @@ next-env.d.ts
*.log
docker-data/
/Documents

View File

@ -0,0 +1 @@
ALTER TABLE "StorefrontChatMessage" ADD COLUMN "actions" JSONB;

View File

@ -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())

View File

@ -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 })

View File

@ -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 ? (

View File

@ -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,

View File

@ -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,

View File

@ -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) {

View File

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

View File

@ -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", () => {

View File

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

View 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,
}
}

View 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")
})
})

View 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(", ")
}

View 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")
})
})

View 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,
})
}