Skip to content

Smart Buylist โ€” customer intake surfaces (kiosk + storefront) โ€‹

Date: 2026-07-14 Status: Approved direction (Brent, 2026-07-14) โ€” needs architect + /office-hours pass before implementation starts Related: #318 (Smart Buylist), #352 (kiosk surface decision), #350 (auth/tenant evolution), #326 (CollectorVision) POC: https://claude.ai/code/artifact/eb9374b0-2b15-4ce9-a8ec-fc0a71e311a3

Summary โ€‹

Extend Smart Buylist (#318) beyond the merchant dashboard with a public, store-scoped intake surface so end customers can create buy orders themselves, either on an in-store kiosk (pinned iPad) or from the shop's website. One server-side quote engine serves every surface; the customer surfaces are submit-only and feed the merchant's existing review queue.

Approved decisions โ€‹

  1. Hosted portal first, theme extension fast-follow. V1 is a hosted page (buylist.lgsforge.com/{shop} or equivalent path) that merchants link from their site. A Shopify Theme App Extension + App Proxy version comes later for on-domain polish. POS UI Extension is out of scope for customer intake (staff-only surface; see #352).
  2. The kiosk is the same hosted portal in kiosk mode (fullscreen PWA, device token, idle reset). This effectively resolves #352 in favor of its Option A for the buylist use case, without deciding the CollectorVision camera question.
  3. Instant quotes default ON, presented as "estimate, subject to review," with a per-store setting to turn them off (per surface if cheap to do).
  4. Email-only guest identity in v1. Shopify customer-account linking is deferred to the theme-extension phase.

Audience shift โ€” why new auth is needed โ€‹

Both existing auth paths assume a known user: embedded mode needs a Shopify Admin session; standalone mode needs an LGS Forge User JWT. Customers have neither. Two new lightweight strategies are required, and both slot into the dualModeAuth โ†’ named-strategies refactor proposed as phase 3 of #350:

  • Kiosk device token โ€” merchant generates in Buylist Settings ("Add kiosk device"). Long-lived, revocable per device, scoped to intake-only endpoints for that one shop. This is the "device login" gap #352 names.
  • App Proxy HMAC verification โ€” for the later theme-extension surface; verify Shopify's signed proxy requests (same shape as the existing verifyWebhook middleware).
  • The hosted-portal web surface runs unauthenticated but store-scoped, protected by rate limits (below).

Architecture โ€‹

One quote engine โ€‹

List parsing โ†’ catalog matching โ†’ rate application lives in one service used by the merchant dashboard preview, the kiosk, and the web portal. Pricing extends the priceLookupService stage pipeline (raw โ†’ modifier โ†’ condition โ†’ rounding) with a buylist-rate stage โ€” extend, don't duplicate (CLAUDE.md ยง5.9). Matching is plugin-driven via BaseGamePlugin; no if (game === ...) in the core (ยง rule 5). Matching runs against the global catalogs (mtg_sets, pokemon_sets, โ€ฆ), not just synced Shopify*Product docs โ€” a customer can sell a card the store hasn't synced; accepting such a line can enqueue a single-card sync.

Public API namespace (server/routes/publicBuylist.js) โ€‹

  • POST /public/buylist/:shop/quote โ€” parse + match + price a list; returns the tentative offer only if the store's instant-quote setting allows.
  • POST /public/buylist/:shop/orders โ€” create BuylistOrder (status: 'pending', source: 'kiosk' | 'storefront'); returns order number + claim token.
  • GET /public/buylist/orders/:id?token=โ€ฆ โ€” customer status page (offer sent โ†’ accept/decline), claim-token-gated; no order enumeration.

All endpoints: Zod schemas in server/schemas/, validate() middleware, game explicit with no default (ยง5.5), aggressive per-IP + per-store rate limiting.

Data model โ€‹

BuylistOrder (per-store collection, like sync_jobs):

  • shop, game, source: 'merchant' | 'kiosk' | 'storefront'
  • customer: { email, name? }, claimTokenHash, kioskDeviceId?
  • lines[]: raw input line, match result (catalog UUID or unmatched), condition, qty, basis price snapshot, rule applied, offer
  • Status machine: pending โ†’ offer_sent โ†’ accepted | declined โ†’ paid
  • Every sub-document field declared field-by-field with a schema-shape round-trip test (ยง5.2).

Store.buylistConfig[gameId] (mirrors pricingConfig[gameId] resolution): price basis, rarity rates, bulk rate, stop-gap rate, condition multipliers, store-credit bonus %, prioritization toggles (out-of-stock boost, hot list), instant-quote visibility, kiosk devices list. Every field consumed in the same PR that adds it (ยง5.9).

Kiosk mode UX โ€‹

Same portal page + kiosk flag with device token: fullscreen, large touch targets, idle-timeout reset to an attract screen, end-of-flow handoff ("You're order #1028 โ€” we'll call you") with optional QR to the customer's status page on their own phone. No customer data persists on the shared device between sessions.

Abuse / price-scraping mitigations โ€‹

A public quote endpoint exposes buylist prices to competitors. Layers, cheapest first: per-IP and per-store rate limits (table stakes); daily quote-line caps per IP; optional Cloudflare Turnstile on the web surface (kiosk exempt via device token); merchant can disable instant quotes on the web surface while keeping them on the kiosk.

PR sequence (small PRs, per CLAUDE.md ยง3) โ€‹

  1. BuylistOrder model + Store.buylistConfig[gameId] + settings UI
  2. Quote engine service + merchant-side import/review (POC screens)
  3. Public API + hosted portal page (customer web surface live)
  4. Kiosk device auth + kiosk mode (attract screen, idle reset, QR handoff)
  5. Theme App Extension + App Proxy (on-domain storefront)
  6. Payout wiring (Shopify gift card via shopifyAPI.js) + customer emails

Phases 1โ€“2 are pure #318; 3โ€“4 deliver kiosk + website; 5โ€“6 are fast-follows.

Open items before implementation โ€‹

  • architect agent pass on the BuylistOrder model + public route/queue design; /office-hours for the auth-adjacent pieces (new public surface, device tokens).
  • Confirm subdomain/CORS line for the portal host (server/index.js allow-list โ€” #350 notes each new subdomain needs an explicit entry).
  • Decide gift-card issuance details at payout (customer account attach vs. anonymous code) during phase 6.
  • Multi-game parity: portal + engine must work for MTG, Pokemon, and Riftbound from day one โ€” Riftbound gained store inventory sync on main (PRs #355โ€“357), so it resolves through the same plugin-driven path as the other games with no exemption needed (ยง5.1).