Appearance
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 }andshop { 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.
| Field | Type | Example | Notes |
|---|---|---|---|
name | String | "Alchemist's Refuge" | Shop display name |
primaryDomainHost | String | "alchemists-refuge.com" | Host only, for link text |
primaryDomainUrl | String | "https://alchemists-refuge.com" | Full URL, for the href |
logoUrl | String | null | Shopify CDN URL | Prefer squareLogo, else logo; null if none |
accentColor | String | null | "#7B2FF7" | #rrggbb only; null if absent/invalid |
fetchedAt | Date | Drives 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,colorsall being null. - Normalize/validate
accentColoragainst^#[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
Storeis saved), callgetShopBranding()and persiststore.branding. Non-fatal: wrap in try/catch, log on failure, never block install. - On sync — in the sync flow, opportunistically refresh when
brandingis missing orfetchedAtis 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"
}
}brandingisnullwhen the store has none yet — client falls back to the generic page.- Expose only the five display fields. Never include tokens,
fetchedAt, or otherStoreinternals on this public, unauthenticated endpoint. - Update the public summary schema/response shape and its tests to cover both the populated and
nullcases.
5. Portal UI
New component PortalHeader (small, presentational) used by both portal pages:
- Renders the logo (
<img>from the Shopify CDNlogoUrl) when present. - Renders the shop name as the page
<h1>. - Renders a "← Back to {storefrontHost}" link to
storefrontUrlwithrel="noopener noreferrer"andtarget="_blank". - Applies accent color sparingly — e.g. a thin header band or the primary submit button — via inline style, only if
accentColorpasses the#rrggbbcheck (belt-and-suspenders with the server validation); otherwise the design-system default.
BuylistPortalPage.jsx:
- Replace the bare
<Text as="h1">Sell your cards</Text>withPortalHeader+ heading "Sell your cards to {name}" (fallback "Sell your cards" whenbrandingornameis absent). - Set
document.titleto reflect the shop (e.g.Sell cards to {name}), reset on unmount.
BuylistPortalStatusPage.jsx:
- Render the same
PortalHeaderso 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.brandingschema round-trip test (§5.2).ShopifyAPI.getShopBranding()unit tests: full brand / null brand / null logo / invalid-color-dropped.getPublicStoreSummarytest: branding passthrough (display fields only) andnullcase.- Portal page tests: renders name + logo + back-link when branding present; falls back to generic heading when
brandingis null; invalid/absent color ignored;document.titleset. - 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.jsround-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.jsorqueues/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 +
ShopifyAPIinstance in hand. - Confirm at build time whether
shop.brandreads under current scopes; if not, ship name + domain and note the scope needed for a later logo/color follow-up.
