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.
  • Backfill โ€” server/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.