Skip to content

Storefront Product Cards & Chat UX

This document describes implemented storefront chat behavior: horizontal product cards under bot replies, Get More Info / View Product actions, reply text cleanup, and server-side focus product context for accurate follow-ups.

Related: RAG & chat API · Admin settings & chat widget · Store currency · RAG retrieval — published products · Vector RAG call stack · Product sync


When the assistant recommends products, the chat API can return a products array. The theme extension renders a horizontal card slider below the bot message (image, title, price, two buttons). The message text still includes names and prices; tags and product page links are stripped when cards are shown so shoppers use the cards for links and metadata.

Cards are built from the assistant reply text only — not from RAG chunks, chat history, or other retrieved context. Only published, active catalog products match (draft/unpublished SKUs are excluded; zero stock is OK).

  1. Titles parsed from the reply (title line before Price:, markdown product links).
  2. Product URLs in the reply (if any).
  3. Full catalog title must appear in the reply (strict substring — shared prefixes like The Collection Snowboard without the variant do not match sibling products).
  4. Matched only to published, active products with a storefront URL.
  5. Up to 6 cards, ordered by first mention in the reply.

The model is instructed to use a fixed multi-product block format (title line → Price: on the next line → blank line → next product; no per-product descriptions) so the parser and product cards stay aligned.

Required LLM format (multi-product / recommendations)

Section titled “Required LLM format (multi-product / recommendations)”

Each product block in the assistant reply:

{Exact catalog title — one line}
Price: {symbol + amount}
{next product title}
Price: {symbol + amount}

Optional intro/outro prose before or after the list is fine. Defined in app/lib/prompt-builder.server.js. Parsed by title + price on consecutive lines in extractProductTitlesFromReply.

flowchart LR
A[LLM reply + RAG chunks] --> B[resolveProductCardsForChat]
B --> C[cleanReplyForProductCards]
C --> D[API: reply + products]
D --> E[chat-widget.js renders message + cards]
E --> F[Get More Info → focusProduct + quoted title]
E --> G[View Product → open PDP]

FieldTablePurpose
image_urlproductsFeatured image URL from Shopify sync (featuredImage) for card thumbnails

Migration: 20260522100000_add_product_image_url. Re-run product sync after deploy so existing rows get images.


ModuleRole
app/lib/product-cards.server.jsResolve cards from RAG chunks + /products/ URLs in reply; toProductCardPayload (prices via formatStoreMoney + shops.currency_code); cleanReplyForProductCards (strip tags, product-type/category lines, product URLs; keep anchor text as plain name); resolveProductCardByUrl
app/lib/rag.server.jsAfter LLM: resolve products, clean reply, update chat_messages.message_text if cleaned; focus product chunk boost + focusProductContext
app/lib/prompt-builder.server.jsSystem rules: quoted product titles; focus product section; name + price only in text when cards show below
app/routes/api.chat.jsxAccepts focusProduct, pageProduct; returns { reply, sessionId, products? }
app/routes/api.product-card.jsxGET ?shop=&url= — lookup card by storefront product URL (widget cache / link fallback)

FieldWhenPurpose
messageAlwaysUser text (max 2000 chars)
shopDomain / shopAlwaysStore domain
sessionIdOptionalThread continuity
appifireAiChatUserIdRecommendedPersistent visitor identity
pageProductOn PDPshopifyProductId, handle, title, selectedVariantId — boosts chunks for product page
focusProductGet More InfoproductId, title, url, handle — one-shot focus; takes priority over pageProduct for that turn
{
"reply": "…assistant markdown…",
"sessionId": "uuid",
"products": [
{
"id": "internal-product-uuid",
"title": "The Multi-managed Snowboard",
"url": "https://store.com/products/the-multi-managed-snowboard",
"imageUrl": "https://cdn.shopify.com/…",
"price": "$629.95",
"priceAmount": 629.95
}
]
}

products is omitted when empty. Debug/RAG internals are never returned to the storefront.


Reply text cleanup (cleanReplyForProductCards)

Section titled “Reply text cleanup (cleanReplyForProductCards)”

When products.length > 0:

Kept: product names, prices / price options, descriptive prose.

Removed:

  • Lines matching Tags:, Product Type:, Category: (with or without bullet prefix)
  • Product page URLs (markdown [label](url) → label text only; bare URLs removed)
  • Trailing orphan phrases (you can also view here, etc.)

Not removed: full product descriptions (duplication with cards is acceptable).

Applied on server before save/response; mirrored in chat-widget.js as cleanReplyTextForProductCards for older cached responses.


Storefront widget (extensions/appifire-chat/assets/chat-widget.js)

Section titled “Storefront widget (extensions/appifire-chat/assets/chat-widget.js)”

All widget DOM ids/classes use the prefix appifire-ai-chat- (e.g. #appifire-ai-chat-window, .appifire-ai-chat-msg). localStorage keys use the same hyphen style: appifire-ai-chat-user-id, appifire-ai-chat-identity-v, appifire-ai-chat-history-{shop}. The chat API body sends appifireAiChatUserId (legacy apfcUserId still accepted server-side). Older apfc_* and appifire-ai-chat_* keys are migrated on read.

  • Font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif
  • Loaded by the widget via Google Fonts (ensureInterFont()), not from the merchant theme
  • #appifire-ai-chat-window and descendants use this stack with !important so storefront theme font-family rules do not change chat text

The embedded admin app may still use Poppins; only the storefront widget is pinned to Inter for cross-store consistency.

  • Container: .appifire-ai-chat-product-cards (horizontal scroll, max 6 cards)
  • Card: image (or placeholder), title, price
  • View Product (primary) — window.open(product.url, '_blank')
  • Get More Info (secondary) — sends user message and focusProduct (see below)

Product links inside the bot message HTML are display: none when cards are attached (cards remain the link affordance).

  1. User-visible message: Tell me more about "The Multi-managed Snowboard" (quotes around exact title; internal " in title → ')
  2. POST body includes:
{
"focusProduct": {
"productId": "<products.id from card>",
"title": "The Multi-managed Snowboard",
"url": "https://…/products/…",
"handle": "the-multi-managed-snowboard"
}
}
  1. RAG loads product by productId or URL, merges product chunks first, injects Focus product (from chat product card) into the system prompt.
  • Cards also built from /products/ links in the bot markdown via GET /api/product-card when not in products array
  • Powered by AppiFire branding block is commented out in markup/CSS (easy to re-enable later)
  • sendMessageWithText(text, sendOpts) supports optional focusProduct

From SYSTEM_PROMPT in prompt-builder.server.js:

  • Quoted product names in the customer message = exact catalog title
  • Focus product section = answer only about that card-selected product
  • When describing recommendations: include name and price; omit tags, product-type labels, and product URLs (cards below)

  • Run npx prisma migrate deploy (includes products.image_url)
  • Re-sync products so image_url is populated
  • Deploy app + theme extension (shopify app deploy or dev)
  • Test: product recommendation → cards + readable reply (no broken “You can also .” sentences)
  • Test: View Product opens PDP; Get More Info returns details for the same SKU

File
extensions/appifire-chat/assets/chat-widget.js
extensions/appifire-chat/blocks/chat-widget-embed.liquid (pageProduct on PDP)
app/routes/api.chat.jsx
app/routes/api.product-card.jsx
app/lib/product-cards.server.js
app/lib/rag.server.js
app/lib/prompt-builder.server.js
app/lib/product-sync.server.js (featuredImageimageUrl)
prisma/migrations/20260522100000_add_product_image_url/