Appearance
Branded Public Buylist Portal Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the public buylist portal visibly belong to the shop (name, logo, back-to-storefront link, accent color) using branding auto-sourced from Shopify, cached on the Store, and served DB-only to the public page.
Architecture: A new getShopBranding() method on ShopifyAPI fetches shop identity from Shopify. A new storeBrandingService persists it to a Store.branding sub-document and refreshes it opportunistically. It is populated at install and on sync (>24h stale), plus a one-time backfill. The public summary endpoint returns display-only branding, and the portal pages render a shared PortalHeader.
Tech Stack: Node/Express (CommonJS), Mongoose, Shopify Admin GraphQL (via server/services/shopifyAPI.js), React 18 + Vite + Tailwind + retroui, Vitest.
Global Constraints โ
- All Shopify API calls go through
server/services/shopifyAPI.js(rule 6). No raw axios/fetch to*.myshopify.com. - New Mongoose sub-document fields are declared field-by-field in the schema, or strict mode drops them silently (ยง5.2). Every new persisted field ships with a schema round-trip test.
- The public endpoint is unauthenticated โ expose only display fields (
name, storefrontHost, storefrontUrl, logoUrl, accentColor); never tokens,fetchedAt, or otherStoreinternals. - Accent color is
#rrggbbonly โ regex^#[0-9a-fA-F]{6}$, validated server-side before storing AND client-side before applying inline style. Any other value โnull/ignored. - Population is always non-fatal โ a Shopify failure at install/sync/backfill is logged and swallowed; it must never break install, sync, or the portal.
- Branding is store-level, not per-game โ no plugin changes; the multi-game parity checklist does not apply. Note this in the PR.
- Tests co-located (
x.test.js), Vitest. Services use_setDeps()/_resetDeps()DI. Server is CommonJS; test files use ESMimport.
File Structure โ
server/models/Store.jsโ addbrandingsub-document (Task 1)server/models/Store.branding.test.jsโ new, schema round-trip (Task 1)server/services/shopifyAPI.jsโ addgetShopBranding()(Task 2)server/services/shopifyAPI.test.jsโ addgetShopBrandingcases (Task 2)server/services/storeBrandingService.jsโ new, fetch/persist/staleness (Task 3)server/services/storeBrandingService.test.jsโ new (Task 3)server/routes/auth.jsโ install-time population call (Task 4)server/queues/processors/syncProcessor.jsโ sync-time staleness refresh call (Task 4)server/services/publicBuylistService.jsโ branding in summary (Task 5)server/services/publicBuylistService.test.jsโ summary branding cases (Task 5)client/src/pages/portal/PortalHeader.jsxโ new shared header (Task 6)client/src/pages/portal/PortalHeader.test.jsxโ new (Task 6)client/src/pages/portal/BuylistPortalPage.jsxโ use header + doc.title (Task 7)client/src/pages/portal/BuylistPortalPage.test.jsxโ branding cases (Task 7)client/src/pages/portal/BuylistPortalStatusPage.jsxโ header (Task 8)client/src/pages/portal/BuylistPortalStatusPage.test.jsxโ branding case (Task 8)server/scripts/data-loading/backfillStoreBranding.jsโ new (Task 9)
Task 1: Store.branding sub-document + round-trip test โ
Files:
- Modify:
server/models/Store.js(addbrandingto the schema) - Create:
server/models/Store.branding.test.js
Interfaces:
Produces:
Store.brandingshape{ name, primaryDomainHost, primaryDomainUrl, logoUrl, accentColor, fetchedAt }โ all optional. Consumed by Tasks 3, 5, 9.[ ] Step 1: Write the failing round-trip test
Create server/models/Store.branding.test.js:
js
import { describe, it, expect } from 'vitest';
import mongoose from 'mongoose';
import Store from './Store.js';
const brandingDoc = {
name: "Alchemist's Refuge",
primaryDomainHost: 'alchemists-refuge.com',
primaryDomainUrl: 'https://alchemists-refuge.com',
logoUrl: 'https://cdn.shopify.com/logo.png',
accentColor: '#7b2ff7',
fetchedAt: new Date('2026-07-19T00:00:00Z')
};
describe('Store.branding sub-document', () => {
it('round-trips every declared branding field (fails if a schema line is dropped)', () => {
const store = new Store({ shop: 'alchemists-refuge.myshopify.com', accessToken: 'x', branding: brandingDoc });
expect(store.branding.name).toBe("Alchemist's Refuge");
expect(store.branding.primaryDomainHost).toBe('alchemists-refuge.com');
expect(store.branding.primaryDomainUrl).toBe('https://alchemists-refuge.com');
expect(store.branding.logoUrl).toBe('https://cdn.shopify.com/logo.png');
expect(store.branding.accentColor).toBe('#7b2ff7');
expect(store.branding.fetchedAt).toEqual(brandingDoc.fetchedAt);
// No validation errors for a store with full branding.
expect(store.validateSync()?.errors?.branding).toBeUndefined();
});
it('is optional โ a store with no branding is valid', () => {
const store = new Store({ shop: 'x.myshopify.com', accessToken: 'x' });
expect(store.branding?.name).toBeUndefined();
});
afterAll(() => mongoose.deletion?.());
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/models/Store.branding.test.js Expected: FAIL โ store.branding is undefined because the field isn't declared (strict mode drops it).
- [ ] Step 3: Add the
brandingsub-document to the schema
In server/models/Store.js, add this block inside the new mongoose.Schema({ ... }) definition (place it near the other feature sub-docs, e.g. right after the buylistConfig field). Declare each field explicitly:
js
// Shop identity for the public buylist portal, auto-sourced from Shopify
// (name/primaryDomain always available; brand logo/colors best-effort).
// Refreshed at install and on sync via storeBrandingService. Every field
// declared explicitly โ strict mode silently drops undeclared sub-doc
// fields on write (ยง5.2).
branding: {
name: { type: String, default: null },
primaryDomainHost: { type: String, default: null },
primaryDomainUrl: { type: String, default: null },
logoUrl: { type: String, default: null },
accentColor: { type: String, default: null },
fetchedAt: { type: Date, default: null },
},- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/models/Store.branding.test.js Expected: PASS (both tests).
- [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.branding.test.js
git commit -m "Add Store.branding sub-document for shop identity"Task 2: ShopifyAPI.getShopBranding() โ
Files:
- Modify:
server/services/shopifyAPI.js(add method to theShopifyAPIclass) - Modify:
server/services/shopifyAPI.test.js(add a describe block)
Interfaces:
Consumes:
this.graphQL(queryString)โ existing method returning the GraphQLdataobject (seeauth.js:161usageshopData?.shop?.plan).Produces:
async getShopBranding()โ{ name, primaryDomainHost, primaryDomainUrl, logoUrl, accentColor, fetchedAt }. Consumed by Task 3.[ ] Step 1: Write the failing tests
Add to server/services/shopifyAPI.test.js (top imports ShopifyAPI already):
js
describe('ShopifyAPI getShopBranding', () => {
let api, graphQLSpy;
beforeEach(() => {
api = new ShopifyAPI('test-shop.myshopify.com', 'token');
graphQLSpy = vi.fn();
api.graphQL = graphQLSpy;
});
it('maps full brand data, preferring squareLogo and a valid hex accent', async () => {
graphQLSpy.mockResolvedValue({
shop: {
name: 'The Card Vault',
primaryDomain: { host: 'cardvault.com', url: 'https://cardvault.com' },
brand: {
logo: { image: { url: 'https://cdn.shopify.com/logo.png' } },
squareLogo: { image: { url: 'https://cdn.shopify.com/square.png' } },
colors: { primary: [{ background: '#7B2FF7' }] }
}
}
});
const b = await api.getShopBranding();
expect(b.name).toBe('The Card Vault');
expect(b.primaryDomainHost).toBe('cardvault.com');
expect(b.primaryDomainUrl).toBe('https://cardvault.com');
expect(b.logoUrl).toBe('https://cdn.shopify.com/square.png'); // squareLogo preferred
expect(b.accentColor).toBe('#7b2ff7'); // lowercased
expect(b.fetchedAt).toBeInstanceOf(Date);
});
it('degrades to name + domain when brand is null', async () => {
graphQLSpy.mockResolvedValue({
shop: { name: 'Bare Shop', primaryDomain: { host: 'bare.com', url: 'https://bare.com' }, brand: null }
});
const b = await api.getShopBranding();
expect(b.name).toBe('Bare Shop');
expect(b.logoUrl).toBeNull();
expect(b.accentColor).toBeNull();
});
it('drops an invalid accent color to null and falls back to logo when no squareLogo', async () => {
graphQLSpy.mockResolvedValue({
shop: {
name: 'X', primaryDomain: { host: 'x.com', url: 'https://x.com' },
brand: { logo: { image: { url: 'https://cdn.shopify.com/l.png' } }, squareLogo: null, colors: { primary: [{ background: 'rgb(1,2,3)' }] } }
}
});
const b = await api.getShopBranding();
expect(b.logoUrl).toBe('https://cdn.shopify.com/l.png');
expect(b.accentColor).toBeNull();
});
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/shopifyAPI.test.js -t getShopBranding Expected: FAIL โ api.getShopBranding is not a function.
- [ ] Step 3: Implement
getShopBranding()
In server/services/shopifyAPI.js, add this method inside the ShopifyAPI class (anywhere among the other async methods, e.g. right after the constructor block or near other shop-level queries):
js
/**
* Fetch shop identity for the public buylist portal. name + primaryDomain
* are always available; brand logo/colors are best-effort (null if the
* merchant never set them, or if our scopes can't read brand). Accent is
* normalized to #rrggbb or null. All Shopify access goes through this file.
*/
async getShopBranding() {
const query = `{
shop {
name
primaryDomain { host url }
brand {
logo { image { url } }
squareLogo { image { url } }
colors { primary { background } }
}
}
}`;
const data = await this.graphQL(query);
const shop = data?.shop || {};
const brand = shop.brand || {};
const logoUrl = brand.squareLogo?.image?.url || brand.logo?.image?.url || null;
const rawColor = brand.colors?.primary?.[0]?.background || null;
const accentColor = typeof rawColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(rawColor)
? rawColor.toLowerCase()
: null;
return {
name: shop.name || null,
primaryDomainHost: shop.primaryDomain?.host || null,
primaryDomainUrl: shop.primaryDomain?.url || null,
logoUrl,
accentColor,
fetchedAt: new Date(),
};
}Note: brand.colors.primary is a list (BrandColorGroup[]) in the Shopify Admin API โ hence primary?.[0]?.background. If the field shape differs on the pinned API version, the optional-chaining + regex simply yields accentColor: null; the page still renders correctly.
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/shopifyAPI.test.js -t getShopBranding Expected: PASS (3 tests).
- [ ] Step 5: Commit
bash
git add server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Fetch shop name, storefront domain, logo, and accent from Shopify"Task 3: storeBrandingService (fetch, persist, staleness) โ
Files:
- Create:
server/services/storeBrandingService.js - Create:
server/services/storeBrandingService.test.js
Interfaces:
Consumes:
ShopifyAPI(Task 2getShopBranding()),Storemodel (Task 1branding).Produces:
fetchAndSaveBranding(shop, accessToken)โPromise<branding>(throws on failure)fetchAndSaveBrandingSafe(shop, accessToken)โ Promise<branding|null> (never throws)refreshBrandingIfStale(store, accessToken)โ Promise<branding|null>isStale(branding)โ boolean- Consumed by Tasks 4 and 9.
[ ] Step 1: Write the failing tests
Create server/services/storeBrandingService.test.js:
js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import service from './storeBrandingService.js';
const branding = { name: 'S', primaryDomainHost: 's.com', primaryDomainUrl: 'https://s.com', logoUrl: null, accentColor: null, fetchedAt: new Date() };
function makeDeps(overrides = {}) {
const getShopBranding = vi.fn().mockResolvedValue(branding);
return {
ShopifyAPI: vi.fn().mockImplementation(() => ({ getShopBranding })),
Store: { updateOne: vi.fn().mockResolvedValue({}) },
logger: { warn: vi.fn(), info: vi.fn() },
_getShopBranding: getShopBranding,
...overrides
};
}
describe('storeBrandingService', () => {
let deps;
beforeEach(() => { deps = makeDeps(); service._setDeps(deps); });
afterEach(() => service._resetDeps());
it('fetchAndSaveBranding writes branding to the store', async () => {
const result = await service.fetchAndSaveBranding('s.myshopify.com', 'tok');
expect(deps.ShopifyAPI).toHaveBeenCalledWith('s.myshopify.com', 'tok');
expect(deps.Store.updateOne).toHaveBeenCalledWith({ shop: 's.myshopify.com' }, { $set: { branding } });
expect(result).toBe(branding);
});
it('fetchAndSaveBrandingSafe swallows errors and logs', async () => {
deps._getShopBranding.mockRejectedValue(new Error('boom'));
const result = await service.fetchAndSaveBrandingSafe('s.myshopify.com', 'tok');
expect(result).toBeNull();
expect(deps.logger.warn).toHaveBeenCalled();
});
it('isStale: true when missing, false when fetched within 24h', () => {
expect(service.isStale(null)).toBe(true);
expect(service.isStale({ fetchedAt: null })).toBe(true);
expect(service.isStale({ fetchedAt: new Date() })).toBe(false);
expect(service.isStale({ fetchedAt: new Date(Date.now() - 25 * 3600 * 1000) })).toBe(true);
});
it('refreshBrandingIfStale refreshes only when stale', async () => {
await service.refreshBrandingIfStale({ shop: 's.myshopify.com', branding: { fetchedAt: new Date() } }, 'tok');
expect(deps.Store.updateOne).not.toHaveBeenCalled();
await service.refreshBrandingIfStale({ shop: 's.myshopify.com', branding: null }, 'tok');
expect(deps.Store.updateOne).toHaveBeenCalledTimes(1);
});
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/storeBrandingService.test.js Expected: FAIL โ module not found.
- [ ] Step 3: Implement the service
Create server/services/storeBrandingService.js:
js
/**
* Fetches shop branding from Shopify and caches it on the Store for the public
* buylist portal. All population is non-fatal โ a Shopify hiccup must never
* break install or sync. The public portal reads Store.branding directly and
* never calls Shopify (see storeBranding in publicBuylistService).
*/
'use strict';
const ShopifyAPI = require('./shopifyAPI');
const Store = require('../models/Store');
const logger = require('../utils/logger');
const STALE_MS = 24 * 60 * 60 * 1000;
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() { return _deps || { ShopifyAPI, Store, logger }; }
async function fetchAndSaveBranding(shop, accessToken, deps = getDeps()) {
const api = new deps.ShopifyAPI(shop, accessToken);
const branding = await api.getShopBranding();
await deps.Store.updateOne({ shop }, { $set: { branding } });
return branding;
}
async function fetchAndSaveBrandingSafe(shop, accessToken, deps = getDeps()) {
try {
return await fetchAndSaveBranding(shop, accessToken, deps);
} catch (error) {
deps.logger.warn('Failed to fetch/save shop branding', { shop, error: error.message });
return null;
}
}
function isStale(branding) {
if (!branding || !branding.fetchedAt) return true;
return Date.now() - new Date(branding.fetchedAt).getTime() > STALE_MS;
}
async function refreshBrandingIfStale(store, accessToken, deps = getDeps()) {
if (!store || !isStale(store.branding)) return null;
return fetchAndSaveBrandingSafe(store.shop, accessToken, deps);
}
module.exports = {
fetchAndSaveBranding,
fetchAndSaveBrandingSafe,
refreshBrandingIfStale,
isStale,
_setDeps,
_resetDeps,
};- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/storeBrandingService.test.js Expected: PASS (4 tests).
- [ ] Step 5: Commit
bash
git add server/services/storeBrandingService.js server/services/storeBrandingService.test.js
git commit -m "Add storeBrandingService to cache and refresh shop branding"Task 4: Populate branding at install and on sync โ
Files:
- Modify:
server/routes/auth.js(install callback, after store save) - Modify:
server/queues/processors/syncProcessor.js(after store load)
Interfaces:
- Consumes:
storeBrandingService.fetchAndSaveBrandingSafe,.refreshBrandingIfStale(Task 3). - Produces:
Store.brandingpopulated on install and kept fresh on sync. No new exports.
This task wires two well-tested calls into existing flows. Both are non-fatal by construction (the service swallows errors). Verification is running the existing server suite (nothing breaks) plus the manual smoke below; the branching logic itself is already covered by Task 3.
- [ ] Step 1: Add install-time population in
auth.js
In server/routes/auth.js, immediately after the Store.findOneAndUpdate(...) call that saves the store (the block ending around line 101, right after logger.info('Store session saved', ...)), add:
js
// Populate shop branding for the public buylist portal (non-fatal).
try {
const storeBrandingService = require('../services/storeBrandingService');
await storeBrandingService.fetchAndSaveBrandingSafe(shop, tokenData.access_token);
} catch (brandingError) {
logger.warn('Failed to populate shop branding at install', { shop, error: brandingError.message });
}(The inner call is already safe; the outer try/catch is belt-and-suspenders consistent with the surrounding install steps.)
- [ ] Step 2: Add sync-time staleness refresh in
syncProcessor.js
In server/queues/processors/syncProcessor.js, immediately after the store is loaded (const store = await Store.findOne({ shop });, ~line 161) and the access token is available (const accessToken = decryptToken(encryptedToken);, ~line 155), add:
js
// Keep the public portal's shop branding fresh (non-fatal, >24h stale).
const storeBrandingService = require('../../services/storeBrandingService');
storeBrandingService.refreshBrandingIfStale(store, accessToken).catch(() => {});Fire-and-forget: it must not delay or fail the sync. Place it after the store null-check that already exists in that handler.
- [ ] Step 3: Run the affected server suites
Run: npx vitest run server/queues/processors/syncProcessor.test.js server/routes (whichever exist) Expected: PASS โ no regressions. (If auth.js has no test file, run npm run lint on it instead: npx eslint server/routes/auth.js server/queues/processors/syncProcessor.js โ 0 errors.)
- [ ] Step 4: Manual smoke (documented, not a unit test)
With a dev store connected, trigger a sync and confirm db.stores.findOne({ shop }).branding.name is populated in MongoDB. Record the result in the PR description.
- [ ] Step 5: Commit
bash
git add server/routes/auth.js server/queues/processors/syncProcessor.js
git commit -m "Populate shop branding at install and refresh it on sync"Task 5: Expose branding in the public summary โ
Files:
- Modify:
server/services/publicBuylistService.js(getPublicStoreSummary) - Modify:
server/services/publicBuylistService.test.js
Interfaces:
Consumes:
store.branding(Task 1).Produces:
GET /api/public/buylist/:shopresponse gainsbranding: { name, storefrontHost, storefrontUrl, logoUrl, accentColor } | null. Consumed by Tasks 7 and 8.[ ] Step 1: Write the failing tests
Add to server/services/publicBuylistService.test.js (it already imports the service and stubs Store; mirror the existing getPublicStoreSummary test setup):
js
describe('getPublicStoreSummary branding', () => {
it('includes display-only branding when present', async () => {
const store = {
shop: 's.myshopify.com',
branding: {
name: 'The Card Vault', primaryDomainHost: 'cardvault.com', primaryDomainUrl: 'https://cardvault.com',
logoUrl: 'https://cdn/x.png', accentColor: '#7b2ff7', fetchedAt: new Date()
}
};
const deps = {
Store: { findOne: vi.fn().mockResolvedValue(store) },
getSupportedGames: () => [], getGameBuylistConfig: () => ({ enabled: false }), getPlugin: () => ({})
};
const result = await service.getPublicStoreSummary('s.myshopify.com', deps);
expect(result.branding).toEqual({
name: 'The Card Vault', storefrontHost: 'cardvault.com', storefrontUrl: 'https://cardvault.com',
logoUrl: 'https://cdn/x.png', accentColor: '#7b2ff7'
});
// fetchedAt is NOT exposed.
expect(result.branding.fetchedAt).toBeUndefined();
});
it('returns branding: null when the store has none', async () => {
const deps = {
Store: { findOne: vi.fn().mockResolvedValue({ shop: 's.myshopify.com' }) },
getSupportedGames: () => [], getGameBuylistConfig: () => ({ enabled: false }), getPlugin: () => ({})
};
const result = await service.getPublicStoreSummary('s.myshopify.com', deps);
expect(result.branding).toBeNull();
});
});(If the test file imports the service as import * as service or default, match the existing import; use the same deps shape the existing summary test uses.)
- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/publicBuylistService.test.js -t "getPublicStoreSummary branding" Expected: FAIL โ result.branding is undefined.
- [ ] Step 3: Implement the branding projection
In server/services/publicBuylistService.js, add a helper above getPublicStoreSummary:
js
// Public, unauthenticated endpoint: expose ONLY display fields, never
// fetchedAt or other Store internals. Null when the store has no branding.
function toPublicBranding(branding) {
if (!branding || !branding.name) return null;
return {
name: branding.name,
storefrontHost: branding.primaryDomainHost || null,
storefrontUrl: branding.primaryDomainUrl || null,
logoUrl: branding.logoUrl || null,
accentColor: branding.accentColor || null,
};
}Then change the return of getPublicStoreSummary from:
js
return { shop: store.shop, games };to:
js
return { shop: store.shop, games, branding: toPublicBranding(store.branding) };- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/publicBuylistService.test.js Expected: PASS (existing + 2 new).
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Return display-only shop branding from the public buylist summary"Task 6: PortalHeader component โ
Files:
- Create:
client/src/pages/portal/PortalHeader.jsx - Create:
client/src/pages/portal/PortalHeader.test.jsx
Interfaces:
Consumes: a
brandingprop{ name, storefrontHost, storefrontUrl, logoUrl, accentColor }(Task 5 shape) ornull.Produces:
default export PortalHeader({ branding }). Consumed by Tasks 7 and 8.[ ] Step 1: Write the failing tests
Create client/src/pages/portal/PortalHeader.test.jsx:
jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import PortalHeader from './PortalHeader';
const full = {
name: 'The Card Vault', storefrontHost: 'cardvault.com', storefrontUrl: 'https://cardvault.com',
logoUrl: 'https://cdn/x.png', accentColor: '#7b2ff7'
};
describe('PortalHeader', () => {
it('renders name, logo, and a back-to-storefront link', () => {
render(<PortalHeader branding={full} />);
expect(screen.getByRole('heading', { name: /The Card Vault/ })).toBeInTheDocument();
expect(screen.getByRole('img', { name: /The Card Vault logo/i })).toHaveAttribute('src', 'https://cdn/x.png');
const link = screen.getByRole('link', { name: /Back to cardvault\.com/i });
expect(link).toHaveAttribute('href', 'https://cardvault.com');
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
});
it('renders name only when logo/domain absent', () => {
render(<PortalHeader branding={{ name: 'Bare Shop', storefrontHost: null, storefrontUrl: null, logoUrl: null, accentColor: null }} />);
expect(screen.getByRole('heading', { name: /Bare Shop/ })).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders nothing when branding is null', () => {
const { container } = render(<PortalHeader branding={null} />);
expect(container).toBeEmptyDOMElement();
});
it('ignores a non-hex accent color (no inline band)', () => {
const { container } = render(<PortalHeader branding={{ ...full, accentColor: 'red; background:url(x)' }} />);
expect(container.querySelector('[style]')).toBeNull();
});
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/PortalHeader.test.jsx Expected: FAIL โ module not found.
- [ ] Step 3: Implement
PortalHeader
Create client/src/pages/portal/PortalHeader.jsx:
jsx
import { Text } from '../../components/retroui';
// Belt-and-suspenders with the server-side validation: only apply a merchant
// color if it is exactly #rrggbb, so a malformed value can never inject CSS.
const HEX = /^#[0-9a-fA-F]{6}$/;
export default function PortalHeader({ branding }) {
if (!branding) return null;
const accent = branding.accentColor && HEX.test(branding.accentColor) ? branding.accentColor : null;
return (
<div className="space-y-2">
{accent && <div className="h-1.5 rounded-full" style={{ backgroundColor: accent }} />}
<div className="flex items-center gap-3">
{branding.logoUrl && (
<img
src={branding.logoUrl}
alt={`${branding.name} logo`}
className="h-10 w-10 rounded object-contain border-2 border-black"
/>
)}
<div>
<Text as="h1">Sell your cards to {branding.name}</Text>
{branding.storefrontUrl && branding.storefrontHost && (
<a
href={branding.storefrontUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm underline"
>
โ Back to {branding.storefrontHost}
</a>
)}
</div>
</div>
</div>
);
}- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/PortalHeader.test.jsx Expected: PASS (4 tests).
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/PortalHeader.jsx client/src/pages/portal/PortalHeader.test.jsx
git commit -m "Add PortalHeader showing shop name, logo, and storefront link"Task 7: Brand the buylist portal page โ
Files:
- Modify:
client/src/pages/portal/BuylistPortalPage.jsx - Modify:
client/src/pages/portal/BuylistPortalPage.test.jsx
Interfaces:
Consumes:
PortalHeader(Task 6);summary.brandingfrom the public summary (Task 5).Produces: branded main portal page. No new exports.
[ ] Step 1: Write the failing test
Add to client/src/pages/portal/BuylistPortalPage.test.jsx (the existing suite already mocks publicApi and renders at a shop; reuse storeSummary but add branding). Add a test:
jsx
it('renders the shop-branded header when the summary includes branding', async () => {
publicApi.get.mockResolvedValueOnce({ data: {
shop: 'test-store.myshopify.com',
games: [{ id: 'mtg', name: 'Magic: The Gathering' }],
branding: { name: 'The Card Vault', storefrontHost: 'cardvault.com', storefrontUrl: 'https://cardvault.com', logoUrl: null, accentColor: null }
}});
renderAt('test-store.myshopify.com');
expect(await screen.findByRole('heading', { name: /Sell your cards to The Card Vault/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Back to cardvault\.com/i })).toBeInTheDocument();
});
it('falls back to the generic heading when branding is null', async () => {
publicApi.get.mockResolvedValueOnce({ data: { shop: 'test-store.myshopify.com', games: [{ id: 'mtg', name: 'Magic: The Gathering' }], branding: null } });
renderAt('test-store.myshopify.com');
expect(await screen.findByRole('heading', { name: /^Sell your cards$/ })).toBeInTheDocument();
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/BuylistPortalPage.test.jsx -t branded Expected: FAIL โ branded heading not found (page still renders the static "Sell your cards").
- [ ] Step 3: Implement branding in the page
In client/src/pages/portal/BuylistPortalPage.jsx:
Add the import at the top (near the other imports):
jsx
import PortalHeader from './PortalHeader';Add a document.title effect right after the existing summary-fetch useEffect (keep it a top-level hook, before any early returns):
jsx
useEffect(() => {
if (!summary?.branding?.name) return;
const previous = document.title;
document.title = `Sell cards to ${summary.branding.name}`;
return () => { document.title = previous; };
}, [summary]);Replace the static heading in the main return:
jsx
<Text as="h1">Sell your cards</Text>with:
jsx
{summary.branding
? <PortalHeader branding={summary.branding} />
: <Text as="h1">Sell your cards</Text>}- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/BuylistPortalPage.test.jsx Expected: PASS (existing + 2 new).
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalPage.jsx client/src/pages/portal/BuylistPortalPage.test.jsx
git commit -m "Show the shop-branded header on the buylist portal page"Task 8: Brand the order-status page โ
Files:
- Modify:
client/src/pages/portal/BuylistPortalStatusPage.jsx - Modify:
client/src/pages/portal/BuylistPortalStatusPage.test.jsx
Interfaces:
Consumes:
PortalHeader(Task 6); the public summary endpointGET /public/buylist/:shop(Task 5) for branding โ the order endpoint does not carry branding, so the page fetches the summary separately.Produces: branded status page. No new exports.
[ ] Step 1: Write the failing test
Add to client/src/pages/portal/BuylistPortalStatusPage.test.jsx (the suite already mocks publicApi and provides an order response). Add a branding fetch mock and test:
jsx
it('renders the shop-branded header from the summary endpoint', async () => {
publicApi.get.mockImplementation((url) => {
if (url === '/public/buylist/test-store.myshopify.com') {
return Promise.resolve({ data: { shop: 'test-store.myshopify.com', games: [], branding: { name: 'The Card Vault', storefrontHost: 'cardvault.com', storefrontUrl: 'https://cardvault.com', logoUrl: null, accentColor: null } } });
}
// order fetch
return Promise.resolve({ data: { order: { status: 'pending', lines: [], payout: {} } } });
});
// renderAt is the existing helper that mounts at /portal/:shop/buylist/orders/:id?token=...
renderAt('test-store.myshopify.com', 'order-1', 'a'.repeat(64));
expect(await screen.findByRole('heading', { name: /The Card Vault/ })).toBeInTheDocument();
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/BuylistPortalStatusPage.test.jsx -t branded Expected: FAIL โ heading not found.
- [ ] Step 3: Implement branding on the status page
In client/src/pages/portal/BuylistPortalStatusPage.jsx:
Add the import:
jsx
import PortalHeader from './PortalHeader';Add branding state and a fetch effect (top-level hooks, alongside the existing order state/effect):
jsx
const [branding, setBranding] = useState(null);
useEffect(() => {
publicApi.get(`/public/buylist/${shop}`)
.then(({ data }) => setBranding(data.branding))
.catch(() => setBranding(null)); // branding is decorative โ never block the status view
}, [shop]);Render <PortalHeader branding={branding} /> at the top of the main returned markup (above the "Your Buy Order" title), and in the loading/error early-return branches if you want it there too (optional; the main view is sufficient).
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run --config vitest.client.config.js client/src/pages/portal/BuylistPortalStatusPage.test.jsx Expected: PASS (existing + 1 new).
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalStatusPage.jsx client/src/pages/portal/BuylistPortalStatusPage.test.jsx
git commit -m "Show the shop-branded header on the order-status page"Task 9: Backfill script for existing stores โ
Files:
- Create:
server/scripts/data-loading/backfillStoreBranding.js
Interfaces:
Consumes:
storeBrandingService.fetchAndSaveBrandingSafe(Task 3),Store,decryptToken.Produces: an idempotent CLI script. No exports required by other tasks.
[ ] Step 1: Implement the script
Create server/scripts/data-loading/backfillStoreBranding.js, following the repo's data-loading script conventions (connect to Mongo, iterate, disconnect):
js
/**
* One-time backfill: populate Store.branding for existing active stores.
* Idempotent and non-fatal per store โ a failure for one store logs and moves
* on. New installs and syncs populate branding on their own; this just does it
* immediately for stores that predate the feature.
*
* Run: node server/scripts/data-loading/backfillStoreBranding.js
*/
'use strict';
require('dotenv').config();
const mongoose = require('mongoose');
const Store = require('../../models/Store');
const { decryptToken } = require('../../utils/crypto');
const storeBrandingService = require('../../services/storeBrandingService');
const logger = require('../../utils/logger');
async function run() {
await mongoose.connect(process.env.MONGODB_URI);
const stores = await Store.find({ isActive: true }).select('shop accessToken').lean();
logger.info(`Backfilling branding for ${stores.length} active stores`);
let ok = 0, skipped = 0;
for (const store of stores) {
const accessToken = store.accessToken ? decryptToken(store.accessToken) : null;
if (!accessToken) { skipped++; continue; }
const result = await storeBrandingService.fetchAndSaveBrandingSafe(store.shop, accessToken);
if (result) ok++; else skipped++;
}
logger.info(`Branding backfill complete: ${ok} populated, ${skipped} skipped`);
await mongoose.disconnect();
}
run().catch(async (error) => {
logger.error('Branding backfill failed', { error: error.message });
await mongoose.disconnect();
process.exit(1);
});- [ ] Step 2: Lint the script
Run: npx eslint server/scripts/data-loading/backfillStoreBranding.js Expected: 0 errors.
- [ ] Step 3: Dry verification (manual)
Against a local/dev Mongo with at least one active store, run node server/scripts/data-loading/backfillStoreBranding.js and confirm the log reports populated counts and db.stores shows branding.name. (No unit test โ this is an operational script; its core logic is covered by Task 3.)
- [ ] Step 4: Commit
bash
git add server/scripts/data-loading/backfillStoreBranding.js
git commit -m "Add backfill script to populate branding for existing stores"Final verification (after all tasks) โ
- [ ]
npm testexits 0. - [ ]
npm run test:clientexits 0. - [ ]
npm run test:coverageโ every modified file โฅ70% on all four metrics. - [ ]
npm run lintexits 0, no new warnings. - [ ]
npm run buildexits 0. - [ ] Manual: install/sync populates
Store.branding; the portal and status pages show the shop name, logo (if set), and a working "โ Back to {storefront}" link; a store with no brand assets still renders name + domain; the public summary response contains nofetchedAt/token fields. - [ ] PR notes: branding is store-level (parity checklist N/A); confirm whether
shop.brandreads under current scopes, and if not, flag the scope for a logo/color follow-up.
Self-review notes (author) โ
- Spec coverage: data model (T1), fetch method (T2), service + staleness (T3), install+sync population (T4), public summary display-only (T5), portal UI + header + doc.title + status page (T6โT8), backfill (T9), graceful degradation (T2/T5/T6 null paths), safety (accent regex both sides T2+T6; display-only projection T5). All spec sections mapped.
- Type consistency: branding object shape
{ name, primaryDomainHost, primaryDomainUrl, logoUrl, accentColor, fetchedAt }is produced in T2, stored in T1, projected to{ name, storefrontHost, storefrontUrl, logoUrl, accentColor }in T5, and consumed under those exact public names in T6โT8. - Known implementation checks (not placeholders): exact
brand.colorsshape verified at build against the pinned Shopify API version (degrades to null accent on mismatch); whethershop.brandis readable under current scopes (degrades to name+domain).
