Skip to content

Branded Public Buylist Portal — Design

Date: 2026-07-19 Status: Approved (design) Author: Brent Ufkes (with Claude) Related: #380 (slug-only portal URLs), #381 ("Share your buylist" settings card), docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md, docs/superpowers/plans/2026-07-18-smart-buylist-phase-3-public-portal.md

Problem

The public buylist portal (/portal/:shop/buylist) currently renders a generic, unbranded page — a bare "Sell your cards" heading with no indication of which shop it belongs to. A customer who follows a merchant's link lands on app.lgsforge.com with nothing tying the page back to the local game store they know. That's a trust gap: the page looks like a random third-party form, not the shop's own buy counter.

We also store no shop identity today. The Store model holds only operational fields (shop domain, tokens, config); there is no shop display name, logo, storefront domain, or brand color anywhere, and the app never queries Shopify's shop metadata.

Goal

Make the public portal visibly belong to the shop, sourced automatically from Shopify with zero merchant effort:

  • The shop's name as the page identity.
  • The shop's logo, when available.
  • A verifiable "← Back to {storefront domain}" link to the shop's real primary storefront — the strongest "this is legit" signal.
  • A light accent of the shop's brand color (accent only, not a full re-theme).

This is the first of two specs. The second spec (out of scope here) covers embedding the portal inside a merchant's own storefront (iframe / App Proxy / Theme App Extension). Branding is the foundation both delivery modes need, so it comes first.

Non-goals (YAGNI / later)

  • Merchant-editable branding overrides (custom display name, logo upload, intro copy). Auto-from-Shopify only for now; overrides can be added later if merchants ask.
  • Full page re-theming with merchant colors (accessibility/contrast risk against the neo-brutalist design system).
  • Favicon swapping.
  • The iframe / on-domain embedding delivery mechanism — next spec.

Approach

Branding data is fetched from Shopify and cached on the Store, then served to the public portal DB-only (no Shopify call on the anonymous request path). This keeps the public, unauthenticated endpoint fast and off Shopify's rate limiter, and self-heals when a merchant rebrands (refreshed on sync).

Rejected alternatives:

  • Lazy fetch on portal load (TTL cache): fresher, but makes an unauthenticated public endpoint trigger Shopify API calls — an abuse/rate-limit surface we don't want to open.
  • Dedicated scheduled refresh job: overkill for data that changes maybe once a year.

Feasibility / constraints

  • shop { name } and shop { primaryDomain { host url } } are readable with the token we already hold — guaranteed baseline.
  • shop { brand { ... } } (logo, colors) is best-effort: it may be null if the merchant never configured brand assets in Shopify, and if our current scopes (write_products, read_products, read_publications, write_publications, read_orders, read_locations, write_inventory) can't read it, we degrade to name + domain. The page must render correctly with only name + domain.
  • Branding is store-level, not per-game — no plugin changes, so the multi-game parity checklist (MULTI_GAME_ARCHITECTURE.md §5.1) does not apply. State this explicitly in the PR.

Design

1. Data model — Store.branding sub-document

Add a branding sub-document to server/models/Store.js, declared field-by-field (Mongoose strict mode silently drops undeclared sub-doc fields — repo failure mode §5.2). All fields optional/nullable so a store with no branding yet is valid.

FieldTypeExampleNotes
nameString"Alchemist's Refuge"Shop display name
primaryDomainHostString"alchemists-refuge.com"Host only, for link text
primaryDomainUrlString"https://alchemists-refuge.com"Full URL, for the href
logoUrlString | nullShopify CDN URLPrefer squareLogo, else logo; null if none
accentColorString | null"#7B2FF7"#rrggbb only; null if absent/invalid
fetchedAtDateDrives staleness refresh

Test (§5.2): a schema-shape round-trip test (write a Store with a full branding sub-doc via the model → read back → assert every field persisted) that would fail if any schema line were removed.

2. Fetch — one method on ShopifyAPI

All Shopify calls go through server/services/shopifyAPI.js (rule 6). Add:

getShopBranding() ->
  {
    name,
    primaryDomainHost,
    primaryDomainUrl,
    logoUrl,        // squareLogo?.image?.url ?? logo?.image?.url ?? null
    accentColor,    // normalized #rrggbb from brand.colors.primary.background, else null
    fetchedAt: <now>
  }

GraphQL (single query):

graphql
{
  shop {
    name
    primaryDomain { host url }
    brand {
      logo { image { url } }
      squareLogo { image { url } }
      colors { primary { background } }
    }
  }
}

Behavior:

  • Tolerate brand, logo, squareLogo, colors all being null.
  • Normalize/validate accentColor against ^#[0-9a-fA-F]{6}$; anything else → null. (Shopify may return shorthand or named forms; only accept full hex, else drop.)
  • Method returns the normalized object; it does not write to the DB (callers persist).

Tests: unit tests with mocked GraphQL for (a) full brand, (b) null brand, (c) brand present but null logo, (d) invalid color string dropped to null.

3. Population (approach ①)

  • On install — in the OAuth callback path (after the Store is saved), call getShopBranding() and persist store.branding. Non-fatal: wrap in try/catch, log on failure, never block install.
  • On sync — in the sync flow, opportunistically refresh when branding is missing or fetchedAt is older than 24h. Reuses the token + rate limiter already present in that path. Non-fatal.
  • Backfillserver/scripts/data-loading/backfillStoreBranding.js: idempotent one-time script iterating active stores, fetching + saving branding. (Existing stores would also populate on their next sync; the script just does it immediately.)

4. Public summary API

Extend getPublicStoreSummary in server/services/publicBuylistService.js so the GET /api/public/buylist/:shop response carries a display-only branding object:

json
{
  "shop": "alchemists-refuge.myshopify.com",
  "games": [ ... ],
  "branding": {
    "name": "Alchemist's Refuge",
    "storefrontHost": "alchemists-refuge.com",
    "storefrontUrl": "https://alchemists-refuge.com",
    "logoUrl": "https://cdn.shopify.com/...",
    "accentColor": "#7B2FF7"
  }
}
  • branding is null when the store has none yet — client falls back to the generic page.
  • Expose only the five display fields. Never include tokens, fetchedAt, or other Store internals on this public, unauthenticated endpoint.
  • Update the public summary schema/response shape and its tests to cover both the populated and null cases.

5. Portal UI

New component PortalHeader (small, presentational) used by both portal pages:

  • Renders the logo (<img> from the Shopify CDN logoUrl) when present.
  • Renders the shop name as the page <h1>.
  • Renders a "← Back to {storefrontHost}" link to storefrontUrl with rel="noopener noreferrer" and target="_blank".
  • Applies accent color sparingly — e.g. a thin header band or the primary submit button — via inline style, only if accentColor passes the #rrggbb check (belt-and-suspenders with the server validation); otherwise the design-system default.

BuylistPortalPage.jsx:

  • Replace the bare <Text as="h1">Sell your cards</Text> with PortalHeader + heading "Sell your cards to {name}" (fallback "Sell your cards" when branding or name is absent).
  • Set document.title to reflect the shop (e.g. Sell cards to {name}), reset on unmount.

BuylistPortalStatusPage.jsx:

  • Render the same PortalHeader so identity is consistent across submit → status.

All UI degrades gracefully: no branding → today's generic page; branding but no logoUrl → name only; no/invalid accentColor → default theme.

6. Safety / graceful degradation

  • Accent color is regex-validated (#rrggbb) at both the server (before storing) and the component (before applying inline style) — prevents any CSS-injection via a malformed value.
  • Logo is a Shopify-CDN URL rendered as an <img src>; no HTML injection surface.
  • Back link uses the Shopify-provided primary-domain URL (trusted origin), rel="noopener noreferrer".
  • Public endpoint exposes only the five display fields — no token/internal leak.
  • Every fetch/population point is non-fatal and logged; a Shopify outage never blocks install, sync, or the portal.

7. Testing

  • Store.branding schema round-trip test (§5.2).
  • ShopifyAPI.getShopBranding() unit tests: full brand / null brand / null logo / invalid-color-dropped.
  • getPublicStoreSummary test: branding passthrough (display fields only) and null case.
  • Portal page tests: renders name + logo + back-link when branding present; falls back to generic heading when branding is null; invalid/absent color ignored; document.title set.
  • Status page test: header renders with branding.
  • Backfill script: light unit or manual verification.
  • Meets the repo bar: npm test, npm run test:client, ≥70% coverage on changed files, npm run lint, npm run build.

Files touched (anticipated)

  • server/models/Store.js (+ .branding.test.js round-trip)
  • server/services/shopifyAPI.js (+ tests) — getShopBranding()
  • server/services/publicBuylistService.js (+ tests) — branding in summary
  • OAuth callback path (install-time population) — server/routes/auth.js / shopifyConnect.js
  • Sync flow (staleness refresh) — server/services/syncService.js or queues/processors/syncProcessor.js
  • server/scripts/data-loading/backfillStoreBranding.js (new)
  • client/src/pages/portal/PortalHeader.jsx (new, + test)
  • client/src/pages/portal/BuylistPortalPage.jsx (+ test)
  • client/src/pages/portal/BuylistPortalStatusPage.jsx (+ test)

Open questions for implementation planning

  • Exact sync-flow insertion point for the staleness refresh (service vs processor) — pick the spot that already has the store record + ShopifyAPI instance in hand.
  • Confirm at build time whether shop.brand reads under current scopes; if not, ship name + domain and note the scope needed for a later logo/color follow-up.