Skip to content

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 other Store internals.
  • Accent color is #rrggbb only โ€” 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 ESM import.

File Structure โ€‹

  • server/models/Store.js โ€” add branding sub-document (Task 1)
  • server/models/Store.branding.test.js โ€” new, schema round-trip (Task 1)
  • server/services/shopifyAPI.js โ€” add getShopBranding() (Task 2)
  • server/services/shopifyAPI.test.js โ€” add getShopBranding cases (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 (add branding to the schema)
  • Create: server/models/Store.branding.test.js

Interfaces:

  • Produces: Store.branding shape { 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 branding sub-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 the ShopifyAPI class)
  • Modify: server/services/shopifyAPI.test.js (add a describe block)

Interfaces:

  • Consumes: this.graphQL(queryString) โ€” existing method returning the GraphQL data object (see auth.js:161 usage shopData?.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 2 getShopBranding()), Store model (Task 1 branding).

  • 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.branding populated 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/:shop response gains branding: { 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 branding prop { name, storefrontHost, storefrontUrl, logoUrl, accentColor } (Task 5 shape) or null.

  • 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.branding from 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 endpoint GET /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 test exits 0.
  • [ ] npm run test:client exits 0.
  • [ ] npm run test:coverage โ€” every modified file โ‰ฅ70% on all four metrics.
  • [ ] npm run lint exits 0, no new warnings.
  • [ ] npm run build exits 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 no fetchedAt/token fields.
  • [ ] PR notes: branding is store-level (parity checklist N/A); confirm whether shop.brand reads 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.colors shape verified at build against the pinned Shopify API version (degrades to null accent on mismatch); whether shop.brand is readable under current scopes (degrades to name+domain).