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.
