Skip to content

Navigation & Dashboard Restructure 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: Replace the tab-based Home.jsx/CatalogTab.jsx shell with a real, route-driven sidebar (Dashboard, Store Settings, per-game Catalog groups, Queue) as specified in docs/superpowers/specs/2026-07-09-navigation-restructure-design.md.

Architecture: React Router v6 nested routes replace the initialTab/showTopTabs prop-drilling pattern. RadixShell.jsx grows a recursive, collapsible nav tree driven by a new enabledCatalogs store preference. Large existing view components (bulk sync, pricing config, catalog browse) are relocated verbatim into new per-route page files — their internals are unchanged, only their container and prop source (route :game param instead of a parent's prop) change.

Tech Stack: React 18, react-router-dom v6, Vitest + Testing Library (client), Express + Zod + Mongoose (server), retroui component library.

Global Constraints

  • No Polaris. All new UI uses retroui primitives (@/components/retroui) + Tailwind, matching existing neo-brutalist styling (border-2 border-black, shadow-[Npx_Npx_0px_0px_rgba(0,0,0,1)]).
  • All API calls go through client/src/utils/api.js's api instance (never raw fetch) — this fixes the existing Catalog.jsx bypass as a side effect of deleting that file.
  • Embedded-mode navigation must always preserve ?shop= via the shared buildShopAwarePath helper (Task 2) — never call navigate(path) directly with a bare path in embedded mode.
  • Client tests follow the existing convention: vi.mock('../utils/api', ...) (or relative equivalent), @testing-library/react + @testing-library/jest-dom, no shared setup file. vitest.client.config.js has no coverage gate — write tests for behavior, not to hit a threshold.
  • Per the approved scope trims (see spec Q&A): Pricing Config and Sync Settings routes are per-game (/catalog/:game/settings/pricing, /catalog/:game/settings/sync) but the underlying data is store-wide, not per-game — both game routes render the same SyncSettingsView-derived components against the same /pricing-config and /price-sync/schedule endpoints. Same for Metafields (/metafield-status, /setup-metafields are store-wide). Each of these three pages carries a one-line UI note ("Applies to all enabled games") so this isn't silently misleading.
  • Riftbound is treated as a third real catalog alongside MTG and Pokemon (toggle in Settings > Catalog, real /catalog/riftbound/* routes) — not a "coming soon" entry.
  • enabledCatalogs replaces the existing dead enabledGames Store field entirely (that field is deleted, not migrated — it was never read or written anywhere, so no data migration is needed).
  • Behavior change (documented, not a regression to chase): the old CatalogTab.jsx let picking a set in the "Sets" tab filter the "Sealed"/"Singles" tabs via shared in-memory state. Since Singles/Sealed/Sets become independent routes with no shared parent, that live cross-tab filter is dropped. Each page keeps its own independent (and where relevant, backend-persisted) set-filter state. This is in scope for this plan — do not attempt to rebuild cross-route filter sharing.

File Structure

New files:

  • client/src/utils/navigation.jsbuildShopAwarePath(), findActiveGroupKeys() (pure helpers, shared by RadixShell and the new sub-nav layouts).
  • client/src/components/SubNavTabs.jsx — flat pill/tab row for in-page sub-navigation (used by Settings and per-game Catalog layouts).
  • client/src/components/NavGroup.jsx — recursive collapsible sidebar nav row (used by RadixShell for the 2–3 level Store Settings / per-game Catalog tree).
  • client/src/components/dashboard/StoreInfoCard.jsx — plan/usage + last sync timestamps.
  • client/src/components/dashboard/AnnouncementsCard.jsx — static placeholder card.
  • client/src/components/dashboard/ActivityCard.jsx — last-5-jobs list + modal + "View all" link.
  • client/src/pages/Queue.jsx — unified job history page.
  • client/src/pages/settings/SettingsLayout.jsx — General/Catalog/Billing sub-nav + <Outlet/>.
  • client/src/pages/settings/GeneralSettings.jsx — Notifications + Sales Channels + Danger Zone.
  • client/src/pages/settings/CatalogSettings.jsxenabledCatalogs toggle list.
  • client/src/pages/settings/BillingSettings.jsx — plan/usage + change-plan link.
  • client/src/pages/catalog/CatalogGameLayout.jsx — validates :game, Singles/Sealed/Sets/Settings sub-nav + <Outlet/>.
  • client/src/pages/catalog/CatalogGameSettingsLayout.jsx — Pricing/Sync Settings/Metafields sub-nav + <Outlet/>.
  • client/src/pages/catalog/CatalogSinglesPage.jsx — relocated CatalogCardsView + CardSetPicker.
  • client/src/pages/catalog/CatalogSealedPage.jsx — relocated CatalogSealedView + grid/list/card sub-components.
  • client/src/pages/catalog/CatalogSetsPage.jsx — relocated CatalogSetsView (browse) + relocated bulk sync flow (SetSelector/SyncButton/SyncStatus/MultiSyncStatus) from Home.jsx.
  • client/src/pages/catalog/CatalogSettingsPricingPage.jsx — relocated pricing-pipeline half of SyncSettingsView.
  • client/src/pages/catalog/CatalogSettingsSyncPage.jsx — relocated price-sync-schedule half of SyncSettingsView.
  • client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx — relocated SetupMetafields usage.

Modified files:

  • client/src/App.jsx — full route table rewrite (Task 23).
  • client/src/components/RadixShell.jsx — full sidebar rewrite (Task 6).
  • client/src/pages/Dashboard.jsx — full rewrite (Task 14).
  • client/src/context/StoreContext.jsx — add enabledCatalogs + refreshPreferences (Task 5).
  • client/src/components/VariantBudgetCard.jsx — fix double-/api prefix bug (Task 11).
  • server/models/Store.js — remove enabledGames, add enabledCatalogs (Task 1).
  • server/schemas/shop.js — extend preferencesUpdateSchema (Task 1).
  • server/routes/api.js — extend GET/PUT /preferences (Task 1).
  • server/schemas/schemas.test.js — add enabledCatalogs cases to the existing preferencesUpdateSchema describe block (Task 1).

Deleted files (Task 23):

  • client/src/pages/Home.jsx
  • client/src/components/CatalogTab.jsx
  • client/src/pages/Catalog.jsx

Task 1: Backend — enabledCatalogs preference

Files:

  • Modify: server/models/Store.js:58-67
  • Modify: server/schemas/shop.js:60-67
  • Modify: server/routes/api.js:1621-1675 (the GET/PUT /preferences handlers)
  • Modify: server/schemas/schemas.test.js:326-341

Interfaces:

  • Produces: GET /api/preferences response gains enabledCatalogs: string[]. PUT /api/preferences accepts optional enabledCatalogs: string[] in its body (in addition to the existing optional-now selectedSets/game).

  • [ ] Step 1: Write the failing schema tests

Add to the existing describe('preferencesUpdateSchema (cross-game)', ...) block in server/schemas/schemas.test.js (right after the it('accepts an empty selectedSets array...') block, before its closing });):

js
    it('accepts a request with only enabledCatalogs and no selectedSets', () => {
        const r = preferencesUpdateSchema.safeParse({ enabledCatalogs: ['mtg', 'pokemon'] });
        expect(r.success).toBe(true);
        expect(r.data.game).toBe('mtg');
    });
    it('accepts enabledCatalogs with all three real games', () => {
        const r = preferencesUpdateSchema.safeParse({ enabledCatalogs: ['mtg', 'pokemon', 'riftbound'] });
        expect(r.success).toBe(true);
    });
    it('rejects an unsupported catalog id', () => {
        const r = preferencesUpdateSchema.safeParse({ enabledCatalogs: ['mtg', 'lorcana'] });
        expect(r.success).toBe(false);
    });
    it('accepts a request with neither selectedSets nor enabledCatalogs (game-only)', () => {
        expect(preferencesUpdateSchema.safeParse({ game: 'pokemon' }).success).toBe(true);
    });
  • [ ] Step 2: Run to verify it fails

Run: npm test -- server/schemas/schemas.test.js Expected: FAIL — enabledCatalogs cases fail because the schema doesn't know that field yet, and selectedSets is still required so the "game-only" case fails too.

  • [ ] Step 3: Update the Zod schema

In server/schemas/shop.js, replace the preferencesUpdateSchema definition (lines 60-67):

js
const preferencesUpdateSchema = z.object({
    selectedSets: z.array(crossGameSetCodeSchema).optional(),
    game: z
        .string()
        .regex(/^[a-z0-9]{2,16}$/, 'Invalid game identifier')
        .optional()
        .default('mtg'),
    enabledCatalogs: z.array(z.enum(['mtg', 'pokemon', 'riftbound'])).optional(),
});
  • [ ] Step 4: Run to verify schema tests pass

Run: npm test -- server/schemas/schemas.test.js Expected: PASS

  • [ ] Step 5: Update the Store model

In server/models/Store.js, delete the enabledGames field (lines 58-67):

js
    enabledGames: {
        type: [String],
        default: ['mtg'],
        validate: {
            validator: function(v) {
                const supportedGames = ['mtg', 'pokemon', 'yugioh', 'lorcana'];
                return v.every(game => supportedGames.includes(game));
            },
            message: 'Invalid game. Supported: mtg, pokemon, yugioh, lorcana'
        }
    },

Replace it with:

js
    // Which real catalogs (games) show in the sidebar / are toggled on for
    // this store. Absent on older docs — Mongoose applies the default below
    // for any store that predates this field, so no migration is needed.
    enabledCatalogs: {
        type: [String],
        default: ['mtg', 'pokemon', 'riftbound'],
        validate: {
            validator: function(v) {
                const supportedCatalogs = ['mtg', 'pokemon', 'riftbound'];
                return v.every(catalog => supportedCatalogs.includes(catalog));
            },
            message: 'Invalid catalog. Supported: mtg, pokemon, riftbound'
        }
    },
  • [ ] Step 6: Update the GET /preferences route

In server/routes/api.js, in the GET /preferences handler (around line 1621), change the final res.json(...) line from:

js
        res.json({ ...legacy, game, selectedSets });

to:

js
        res.json({ ...legacy, game, selectedSets, enabledCatalogs: req.store.enabledCatalogs });
  • [ ] Step 7: Update the PUT /preferences route

Replace the entire router.put('/preferences', ...) handler body (around line 1646) with:

js
router.put('/preferences', validate(preferencesUpdateSchema), async (req, res) => {
    try {
        const { selectedSets, game = 'mtg', enabledCatalogs } = req.body;

        const { isGameSupported } = require('../plugins');
        if (!isGameSupported(game)) {
            return res.status(400).json({ error: 'Unsupported game', message: `Game '${game}' is not supported.` });
        }

        let normalized;
        if (selectedSets !== undefined) {
            // MTG codes are canonically uppercase; other games use case-preserving slugs.
            normalized = game === 'mtg' ? selectedSets.map(c => c.toUpperCase()) : selectedSets;

            if (!req.store.gameConfig) req.store.gameConfig = {};
            if (!req.store.gameConfig[game]) req.store.gameConfig[game] = {};
            req.store.gameConfig[game].selectedSets = normalized;

            // Mirror MTG to the legacy bucket so /sync/start and older readers stay correct.
            if (game === 'mtg') {
                req.store.preferences = req.store.preferences || {};
                req.store.preferences.selectedSets = normalized;
            }
            req.store.markModified('gameConfig');
        }

        if (enabledCatalogs !== undefined) {
            req.store.enabledCatalogs = enabledCatalogs;
        }

        await req.store.save();

        res.json({
            success: true,
            game,
            ...(normalized !== undefined && { selectedSets: normalized }),
            ...(enabledCatalogs !== undefined && { enabledCatalogs }),
        });
    } catch (error) {
        logger.error('Failed to update preferences', { error: error.message });
        res.status(500).json({ error: 'Failed to update preferences' });
    }
});
  • [ ] Step 8: Run full server test suite

Run: npm test Expected: PASS (no regressions — enabledGames had zero other references per the pre-plan codebase audit).

  • [ ] Step 9: Commit
bash
git add server/models/Store.js server/schemas/shop.js server/routes/api.js server/schemas/schemas.test.js
git commit -m "feat(server): add enabledCatalogs store preference, replace dead enabledGames field"

Task 2: Client — shared navigation helpers

Files:

  • Create: client/src/utils/navigation.js
  • Test: client/src/utils/navigation.test.js

Interfaces:

  • Produces: buildShopAwarePath(path, isEmbedded, searchParams) → string. findActiveGroupKeys(navTree, pathname)string[]. Both consumed by Task 6 (RadixShell), Task 15 (CatalogGameLayout), Task 7 (SettingsLayout).

  • [ ] Step 1: Write the failing tests

js
// client/src/utils/navigation.test.js
import { describe, it, expect } from 'vitest';
import { buildShopAwarePath, findActiveGroupKeys } from './navigation';

describe('buildShopAwarePath', () => {
  it('returns the bare path in standalone mode', () => {
    const params = new URLSearchParams();
    expect(buildShopAwarePath('/dashboard', false, params)).toBe('/dashboard');
  });

  it('appends ?shop= from URL searchParams in embedded mode', () => {
    const params = new URLSearchParams('shop=test.myshopify.com');
    expect(buildShopAwarePath('/queue', true, params)).toBe('/queue?shop=test.myshopify.com');
  });

  it('falls back to sessionStorage when searchParams has no shop', () => {
    sessionStorage.setItem('shopify_embedded_shop', 'fallback.myshopify.com');
    const params = new URLSearchParams();
    expect(buildShopAwarePath('/queue', true, params)).toBe('/queue?shop=fallback.myshopify.com');
    sessionStorage.removeItem('shopify_embedded_shop');
  });

  it('returns the bare path in embedded mode with no shop anywhere', () => {
    sessionStorage.removeItem('shopify_embedded_shop');
    const params = new URLSearchParams();
    expect(buildShopAwarePath('/queue', true, params)).toBe('/queue');
  });
});

describe('findActiveGroupKeys', () => {
  const tree = [
    { key: 'dashboard', path: '/dashboard' },
    {
      key: 'settings', label: 'Store Settings', children: [
        { key: 'settings-general', path: '/settings/general' },
        { key: 'settings-catalog', path: '/settings/catalog' },
      ],
    },
    {
      key: 'catalog-mtg', label: 'MTG', children: [
        { key: 'catalog-mtg-singles', path: '/catalog/mtg/singles' },
        {
          key: 'catalog-mtg-settings', label: 'Settings', children: [
            { key: 'catalog-mtg-settings-pricing', path: '/catalog/mtg/settings/pricing' },
          ],
        },
      ],
    },
  ];

  it('returns an empty array when nothing matches', () => {
    expect(findActiveGroupKeys(tree, '/queue')).toEqual([]);
  });

  it('returns the single ancestor group for a 2-level match', () => {
    expect(findActiveGroupKeys(tree, '/settings/catalog')).toEqual(['settings']);
  });

  it('returns both ancestor groups for a 3-level match', () => {
    expect(findActiveGroupKeys(tree, '/catalog/mtg/settings/pricing')).toEqual(['catalog-mtg', 'catalog-mtg-settings']);
  });

  it('matches leaf paths by prefix (sub-routes of a leaf)', () => {
    expect(findActiveGroupKeys(tree, '/catalog/mtg/singles/some-card')).toEqual(['catalog-mtg']);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/utils/navigation.test.js Expected: FAIL — navigation.js doesn't exist.

  • [ ] Step 3: Implement
js
// client/src/utils/navigation.js

/**
 * Build a nav path that preserves ?shop= across client-side navigation in
 * embedded mode, matching the pattern RadixShell has always used for its
 * flat nav so nested/collapsible groups behave identically.
 */
export function buildShopAwarePath(path, isEmbedded, searchParams) {
  if (!isEmbedded) return path;
  const shop = searchParams.get('shop') || sessionStorage.getItem('shopify_embedded_shop');
  return shop ? `${path}?shop=${encodeURIComponent(shop)}` : path;
}

/**
 * Walk a nav tree (nodes are either { key, path } leaves or
 * { key, children } groups) and return the list of ancestor group keys
 * whose subtree contains a leaf matching `pathname` (exact match or a
 * path segment prefix of it). Used to auto-expand the sidebar group
 * containing the active route.
 */
export function findActiveGroupKeys(navTree, pathname) {
  const keys = [];

  function walk(nodes, ancestry) {
    for (const node of nodes) {
      if (node.children) {
        if (walk(node.children, [...ancestry, node.key])) {
          keys.push(...ancestry, node.key);
          return true;
        }
      } else if (node.path && (pathname === node.path || pathname.startsWith(`${node.path}/`))) {
        keys.push(...ancestry);
        return true;
      }
    }
    return false;
  }

  walk(navTree, []);
  return keys;
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/utils/navigation.test.js Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/utils/navigation.js client/src/utils/navigation.test.js
git commit -m "feat(client): add shop-aware path and active-group-key nav helpers"

Task 3: Client — SubNavTabs component

Files:

  • Create: client/src/components/SubNavTabs.jsx
  • Test: client/src/components/SubNavTabs.test.jsx

Interfaces:

  • Consumes: buildShopAwarePath (Task 2).

  • Produces: <SubNavTabs items={[{label, path}]} isEmbedded /> — a Card-wrapped row of Buttons, active one highlighted, click navigates preserving ?shop=. Consumed by Task 15 (CatalogGameLayout), Task 19 (CatalogGameSettingsLayout), Task 7 (SettingsLayout).

  • [ ] Step 1: Write the failing test

jsx
// client/src/components/SubNavTabs.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import SubNavTabs from './SubNavTabs';

const items = [
  { label: 'Singles', path: '/catalog/mtg/singles' },
  { label: 'Sealed', path: '/catalog/mtg/sealed' },
];

describe('SubNavTabs', () => {
  it('renders a button per item and marks the active one', () => {
    render(
      <MemoryRouter initialEntries={['/catalog/mtg/singles']}>
        <SubNavTabs items={items} isEmbedded={false} />
      </MemoryRouter>
    );
    expect(screen.getByRole('button', { name: 'Singles' })).toHaveClass('nav-tab-active');
    expect(screen.getByRole('button', { name: 'Sealed' })).not.toHaveClass('nav-tab-active');
  });

  it('navigates to the clicked tab, preserving shop in embedded mode', () => {
    render(
      <MemoryRouter initialEntries={['/catalog/mtg/singles?shop=test.myshopify.com']}>
        <SubNavTabs items={items} isEmbedded={true} />
      </MemoryRouter>
    );
    fireEvent.click(screen.getByRole('button', { name: 'Sealed' }));
    // Navigation itself is exercised via App-level routing tests (Task 23);
    // here we just confirm the click doesn't throw and the button is present.
    expect(screen.getByRole('button', { name: 'Sealed' })).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/SubNavTabs.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/components/SubNavTabs.jsx
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Card, Button } from '@/components/retroui';
import { buildShopAwarePath } from '../utils/navigation';

// Flat pill-row sub-navigation used inside per-section layouts
// (Settings > General/Catalog/Billing, Catalog > Singles/Sealed/Sets/Settings,
// Catalog Settings > Pricing/Sync Settings/Metafields).
export default function SubNavTabs({ items, isEmbedded = false }) {
  const location = useLocation();
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();

  const isActive = (path) => location.pathname === path || location.pathname.startsWith(`${path}/`);

  return (
    <Card className="p-2 sm:p-3 md:p-4">
      <div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-1.5 sm:gap-2">
        {items.map((item) => (
          <Button
            key={item.path}
            variant={isActive(item.path) ? 'default' : 'outline'}
            onClick={() => navigate(buildShopAwarePath(item.path, isEmbedded, searchParams))}
            className={`sm:flex-none sm:min-w-[100px] ${isActive(item.path) ? 'nav-tab-active' : ''}`}
          >
            {item.label}
          </Button>
        ))}
      </div>
    </Card>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/SubNavTabs.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/components/SubNavTabs.jsx client/src/components/SubNavTabs.test.jsx
git commit -m "feat(client): add SubNavTabs shared sub-navigation component"

Task 4: Client — NavGroup recursive sidebar component

Files:

  • Create: client/src/components/NavGroup.jsx
  • Test: client/src/components/NavGroup.test.jsx

Interfaces:

  • Consumes: nothing external (pure presentational + local navigate callback).

  • Produces: <NavGroup item={node} depth={0} isActive={fn} expandedKeys={Set} onToggle={fn} onNavigate={fn} />. A node is { key, label, path } (leaf) or { key, label, children: [...] } (group). Consumed by Task 6 (RadixShell).

  • [ ] Step 1: Write the failing test

jsx
// client/src/components/NavGroup.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import NavGroup from './NavGroup';

describe('NavGroup', () => {
  it('renders a leaf item as a button and calls onNavigate when clicked', () => {
    const onNavigate = vi.fn();
    render(
      <NavGroup
        item={{ key: 'dashboard', label: 'Dashboard', path: '/dashboard' }}
        depth={0}
        isActive={() => false}
        expandedKeys={new Set()}
        onToggle={() => {}}
        onNavigate={onNavigate}
      />
    );
    fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
    expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ path: '/dashboard' }));
  });

  it('renders a group as a toggle and does not show children when collapsed', () => {
    const onToggle = vi.fn();
    render(
      <NavGroup
        item={{ key: 'settings', label: 'Store Settings', children: [{ key: 'g', label: 'General', path: '/settings/general' }] }}
        depth={0}
        isActive={() => false}
        expandedKeys={new Set()}
        onToggle={onToggle}
        onNavigate={() => {}}
      />
    );
    expect(screen.queryByRole('button', { name: 'General' })).not.toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: 'Store Settings' }));
    expect(onToggle).toHaveBeenCalledWith('settings');
  });

  it('shows children when the group key is in expandedKeys', () => {
    render(
      <NavGroup
        item={{ key: 'settings', label: 'Store Settings', children: [{ key: 'g', label: 'General', path: '/settings/general' }] }}
        depth={0}
        isActive={() => false}
        expandedKeys={new Set(['settings'])}
        onToggle={() => {}}
        onNavigate={() => {}}
      />
    );
    expect(screen.getByRole('button', { name: 'General' })).toBeInTheDocument();
  });

  it('marks a leaf active based on isActive(path)', () => {
    render(
      <NavGroup
        item={{ key: 'dashboard', label: 'Dashboard', path: '/dashboard' }}
        depth={0}
        isActive={(p) => p === '/dashboard'}
        expandedKeys={new Set()}
        onToggle={() => {}}
        onNavigate={() => {}}
      />
    );
    expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveClass('nav-link-active');
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/NavGroup.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/components/NavGroup.jsx
import { ChevronDownIcon, ChevronRightIcon } from '@radix-ui/react-icons';
import { Badge } from '@/components/retroui';

// Recursive sidebar nav row. A node with `children` renders as a
// collapsible group; a node with `path` renders as a leaf link.
// Used for the 2-3 level Store Settings / per-game Catalog tree in RadixShell.
export default function NavGroup({ item, depth, isActive, expandedKeys, onToggle, onNavigate }) {
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
  const paddingClass = depth === 0 ? '' : depth === 1 ? 'pl-4' : 'pl-8';

  if (!hasChildren) {
    const active = isActive(item.path);
    return (
      <button
        onClick={() => onNavigate(item)}
        disabled={item.disabled}
        className={`nav-link w-full text-sm ${paddingClass} ${item.disabled ? 'nav-link-disabled' : active ? 'nav-link-active' : 'nav-link-default'} ${item.comingSoon ? 'justify-between' : ''}`}
      >
        <span>{item.label}</span>
        {item.comingSoon && <Badge variant="info">Coming Soon</Badge>}
      </button>
    );
  }

  const isExpanded = expandedKeys.has(item.key);

  return (
    <div>
      <button
        onClick={() => onToggle(item.key)}
        className={`nav-link w-full text-sm ${paddingClass} justify-between nav-link-default`}
        aria-expanded={isExpanded}
      >
        <span>{item.label}</span>
        {isExpanded ? <ChevronDownIcon className="w-3.5 h-3.5" /> : <ChevronRightIcon className="w-3.5 h-3.5" />}
      </button>
      {isExpanded && (
        <div className="space-y-1 mt-1">
          {item.children.map((child) => (
            <NavGroup
              key={child.key}
              item={child}
              depth={depth + 1}
              isActive={isActive}
              expandedKeys={expandedKeys}
              onToggle={onToggle}
              onNavigate={onNavigate}
            />
          ))}
        </div>
      )}
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/NavGroup.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/components/NavGroup.jsx client/src/components/NavGroup.test.jsx
git commit -m "feat(client): add recursive NavGroup sidebar component"

Task 5: StoreContext — expose enabledCatalogs

Files:

  • Modify: client/src/context/StoreContext.jsx
  • Test: client/src/context/StoreContext.test.jsx

Interfaces:

  • Consumes: api.get('/preferences') (Task 1's extended response).

  • Produces: context value gains enabledCatalogs: string[] (default ['mtg','pokemon','riftbound'] until loaded) and refreshPreferences: () => Promise<void>. Consumed by Task 6 (RadixShell) and Task 8 (CatalogSettings page, to refresh after saving toggles).

  • [ ] Step 1: Write the failing test

jsx
// client/src/context/StoreContext.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';

vi.mock('../utils/api', () => ({
  default: { get: vi.fn() },
  userApi: { me: vi.fn() },
  getSelectedShop: vi.fn(() => 'test.myshopify.com'),
  setSelectedShop: vi.fn(),
  waitForAppBridge: vi.fn(() => Promise.resolve()),
}));

import api, { userApi } from '../utils/api';
import { StoreProvider, useStore } from './StoreContext';

function Probe() {
  const { enabledCatalogs } = useStore();
  return <div data-testid="catalogs">{enabledCatalogs.join(',')}</div>;
}

describe('StoreContext enabledCatalogs', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    userApi.me.mockResolvedValue({ data: { user: { connectedStores: [{ shop: 'test.myshopify.com' }] } } });
  });

  it('defaults to all three real catalogs before the fetch resolves', () => {
    api.get.mockReturnValue(new Promise(() => {})); // never resolves
    render(
      <MemoryRouter>
        <StoreProvider isEmbedded={false}>
          <Probe />
        </StoreProvider>
      </MemoryRouter>
    );
    expect(screen.getByTestId('catalogs')).toHaveTextContent('mtg,pokemon,riftbound');
  });

  it('updates from the /preferences response once loaded', async () => {
    api.get.mockImplementation((url) => {
      if (url === '/preferences') {
        return Promise.resolve({ data: { enabledCatalogs: ['mtg'] } });
      }
      return Promise.resolve({ data: {} });
    });
    render(
      <MemoryRouter>
        <StoreProvider isEmbedded={false}>
          <Probe />
        </StoreProvider>
      </MemoryRouter>
    );
    await waitFor(() => {
      expect(screen.getByTestId('catalogs')).toHaveTextContent('mtg');
    });
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/context/StoreContext.test.jsx Expected: FAIL — enabledCatalogs is undefined on the context value.

  • [ ] Step 3: Implement

In client/src/context/StoreContext.jsx:

Add a new state field after the error state (line 20):

js
  const [enabledCatalogs, setEnabledCatalogs] = useState(['mtg', 'pokemon', 'riftbound']);

Add a loadPreferences/refreshPreferences callback after refreshStores (after line 176):

js
  const refreshPreferences = useCallback(async () => {
    try {
      const { data } = await api.get('/preferences');
      if (Array.isArray(data?.enabledCatalogs)) {
        setEnabledCatalogs(data.enabledCatalogs);
      }
    } catch (err) {
      console.error('Failed to load catalog preferences:', err);
    }
  }, []);

Add an effect that calls it whenever there's an active shop (after the activeStore useMemo, around line 187):

js
  useEffect(() => {
    if (!activeShop) return;
    refreshPreferences();
  }, [activeShop, refreshPreferences]);

Add enabledCatalogs and refreshPreferences to the memoized value (lines 202-213):

js
  const value = useMemo(() => ({
    stores,
    activeShop,
    activeStore,
    activeStoreLabel,
    hasActiveConnectedStore,
    setActiveShop,
    refreshStores,
    enabledCatalogs,
    refreshPreferences,
    loading,
    error,
    sessionChecked,
  }), [stores, activeShop, activeStore, activeStoreLabel, hasActiveConnectedStore, setActiveShop, refreshStores, enabledCatalogs, refreshPreferences, loading, error, sessionChecked]);

Note: api needs to be imported as the default export too — check the top of the file already has import api, { userApi, ... } from '../utils/api'; (it does, line 3) — no import change needed.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/context/StoreContext.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/context/StoreContext.jsx client/src/context/StoreContext.test.jsx
git commit -m "feat(client): expose enabledCatalogs from StoreContext"

Task 6: RadixShell.jsx — sidebar restructure

Files:

  • Modify: client/src/components/RadixShell.jsx (full rewrite of the nav-building logic and JSX body; header/theme/sign-out chrome at the top is unchanged)
  • Test: client/src/components/RadixShell.test.jsx

Interfaces:

  • Consumes: useStore() → now also enabledCatalogs (Task 5). buildShopAwarePath, findActiveGroupKeys (Task 2). NavGroup (Task 4). api.get('/games').

  • Produces: sidebar renders Account (Dashboard, Store Settings ▸ General/Catalog/Billing, Queue), Catalog (one expandable group per enabledCatalogs entry, each with Singles/Sealed/Sets/Settings ▸ Pricing/Sync Settings/Metafields, plus "+ Add catalog" → /settings/catalog), Tools (Buylist, coming soon).

  • [ ] Step 1: Write the failing tests

jsx
// client/src/components/RadixShell.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';

vi.mock('../utils/api', () => ({
  default: { get: vi.fn(() => Promise.resolve({ data: { games: [
    { id: 'mtg', name: 'Magic: The Gathering' },
    { id: 'pokemon', name: 'Pokemon' },
    { id: 'riftbound', name: 'Riftbound' },
  ] } })) },
  logout: vi.fn(),
}));

vi.mock('../context/StoreContext', () => ({
  useStore: () => ({
    stores: [],
    activeShop: 'test.myshopify.com',
    activeStoreLabel: 'test.myshopify.com',
    hasActiveConnectedStore: true,
    setActiveShop: vi.fn(),
    enabledCatalogs: ['mtg', 'pokemon'],
    loading: false,
  }),
}));

vi.mock('../context/ThemeContext', () => ({
  useTheme: () => ({ isDark: false, toggleDarkMode: vi.fn() }),
}));

import RadixShell from './RadixShell';

function renderShell(path = '/dashboard') {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <RadixShell isEmbedded={false}>
        <div>content</div>
      </RadixShell>
    </MemoryRouter>
  );
}

describe('RadixShell sidebar', () => {
  beforeEach(() => vi.clearAllMocks());

  it('renders one catalog group per enabled game and not for disabled ones', async () => {
    renderShell();
    await waitFor(() => {
      expect(screen.getByRole('button', { name: /Magic: The Gathering/i })).toBeInTheDocument();
    });
    expect(screen.getByRole('button', { name: /Pokemon/i })).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /Riftbound/i })).not.toBeInTheDocument();
  });

  it('auto-expands the group containing the active route', async () => {
    renderShell('/catalog/mtg/singles');
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Singles' })).toBeInTheDocument();
    });
  });

  it('shows a Coming Soon badge for Buylist and opens the dialog on click', async () => {
    renderShell();
    await waitFor(() => screen.getByText('Buylist'));
    fireEvent.click(screen.getByText('Buylist'));
    expect(await screen.findByText('Coming Soon')).toBeInTheDocument();
  });

  it('renders a Store Settings group with General/Catalog/Billing children when expanded', async () => {
    renderShell();
    await waitFor(() => screen.getByRole('button', { name: 'Store Settings' }));
    fireEvent.click(screen.getByRole('button', { name: 'Store Settings' }));
    expect(screen.getByRole('button', { name: 'General' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Catalog' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Billing' })).toBeInTheDocument();
  });

  it('renders a top-level Queue link', async () => {
    renderShell();
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Queue' })).toBeInTheDocument();
    });
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/RadixShell.test.jsx Expected: FAIL — current RadixShell has none of this structure.

  • [ ] Step 3: Implement

Replace client/src/components/RadixShell.jsx in full:

jsx
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import * as Dialog from "@radix-ui/react-dialog";
import {
  HamburgerMenuIcon,
  Cross1Icon,
  DashboardIcon,
  UpdateIcon,
  GearIcon,
  ExitIcon,
  ListBulletIcon,
} from "@radix-ui/react-icons";
import { useStore } from "../context/StoreContext";
import { useTheme } from "../context/ThemeContext";
import { logout } from "../utils/api";
import api from "../utils/api";
import { Badge, Button, Select, Text } from "@/components/retroui";
import ThemeSelector from "./ThemeSelector";
import NavGroup from "./NavGroup";
import { buildShopAwarePath, findActiveGroupKeys } from "../utils/navigation";
import { Moon, Sun } from "lucide-react";

function buildCatalogGroup(game) {
  const base = `/catalog/${game.id}`;
  return {
    key: `catalog-${game.id}`,
    label: game.name,
    children: [
      { key: `catalog-${game.id}-singles`, label: "Singles", path: `${base}/singles` },
      { key: `catalog-${game.id}-sealed`, label: "Sealed", path: `${base}/sealed` },
      { key: `catalog-${game.id}-sets`, label: "Sets", path: `${base}/sets` },
      {
        key: `catalog-${game.id}-settings`, label: "Settings", children: [
          { key: `catalog-${game.id}-settings-pricing`, label: "Pricing", path: `${base}/settings/pricing` },
          { key: `catalog-${game.id}-settings-sync`, label: "Sync Settings", path: `${base}/settings/sync` },
          { key: `catalog-${game.id}-settings-metafields`, label: "Metafields", path: `${base}/settings/metafields` },
        ],
      },
    ],
  };
}

export default function RadixShell({ children, isEmbedded = false }) {
  const navigate = useNavigate();
  const location = useLocation();
  const [searchParams] = useSearchParams();
  const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth >= 768);
  const [comingSoonDialogOpen, setComingSoonDialogOpen] = useState(false);
  const [comingSoonProduct, setComingSoonProduct] = useState("");
  const [allGames, setAllGames] = useState([]);
  const [expandedKeys, setExpandedKeys] = useState(new Set());

  const { isDark, toggleDarkMode } = useTheme();

  const {
    stores,
    activeShop,
    activeStoreLabel,
    hasActiveConnectedStore,
    setActiveShop,
    enabledCatalogs,
    loading,
  } = useStore();

  useEffect(() => {
    let cancelled = false;
    api.get("/games").then(({ data }) => {
      if (!cancelled) setAllGames(data.games || []);
    }).catch(() => {
      if (!cancelled) setAllGames([]);
    });
    return () => { cancelled = true; };
  }, []);

  const accountGroup = {
    key: "account",
    children: [
      { key: "dashboard", label: "Dashboard", path: "/dashboard" },
      {
        key: "settings", label: "Store Settings", children: [
          { key: "settings-general", label: "General", path: "/settings/general" },
          { key: "settings-catalog", label: "Catalog", path: "/settings/catalog" },
          { key: "settings-billing", label: "Billing", path: "/settings/billing" },
        ],
      },
      { key: "queue", label: "Queue", path: "/queue" },
    ],
  };

  const catalogChildren = useMemo(() => {
    const enabledGameObjs = allGames.filter((g) => enabledCatalogs.includes(g.id));
    return enabledGameObjs.map(buildCatalogGroup);
  }, [allGames, enabledCatalogs]);

  const toolsGroup = {
    key: "tools",
    children: [
      { key: "buylist", label: "Buylist", comingSoon: true },
    ],
  };

  const navTree = useMemo(() => [accountGroup, { key: "catalog", children: catalogChildren }, toolsGroup], [catalogChildren]);

  useEffect(() => {
    const active = findActiveGroupKeys(navTree, location.pathname);
    if (active.length === 0) return;
    setExpandedKeys((prev) => {
      const next = new Set(prev);
      active.forEach((k) => next.add(k));
      return next;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [location.pathname, catalogChildren.length]);

  const isActive = (path) => !!path && (location.pathname === path || location.pathname.startsWith(`${path}/`));

  const toggleGroup = (key) => {
    setExpandedKeys((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key); else next.add(key);
      return next;
    });
  };

  const handleComingSoonClick = (label) => {
    setComingSoonProduct(label);
    setComingSoonDialogOpen(true);
  };

  const handleNavigate = (item) => {
    if (item.comingSoon) {
      handleComingSoonClick(item.label);
      return;
    }
    if (!isEmbedded && !hasActiveConnectedStore) return;
    navigate(buildShopAwarePath(item.path, isEmbedded, searchParams));
  };

  return (
    <div className="min-h-screen flex flex-col bg-background">
      {/* Top Bar */}
      <header className="bg-background border-b-3 border-black sticky top-0 z-50">
        <div className="flex items-center justify-between min-h-[3.5rem] px-2 sm:px-4 py-2">
          <div className="flex items-center gap-2 sm:gap-3 min-w-0">
            <Button
              variant="outline"
              size="sm"
              onClick={() => setSidebarOpen(!sidebarOpen)}
              aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
              className="flex-shrink-0"
            >
              {sidebarOpen ? <Cross1Icon /> : <HamburgerMenuIcon />}
            </Button>
            <Text as="span" className="text-base sm:text-lg font-bold text-foreground truncate">
              LGS Forge
            </Text>
          </div>
          <div className="flex items-center gap-1 sm:gap-3 min-w-0">
            <ThemeSelector />
            <Button
              variant="outline"
              size="sm"
              onClick={toggleDarkMode}
              aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
              className="flex-shrink-0"
            >
              {isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
            </Button>
            {!isEmbedded && (
              <Button variant="outline" size="sm" onClick={logout} className="text-foreground hover:text-primary flex-shrink-0">
                <ExitIcon />
                <span className="hidden sm:inline ml-1">Sign Out</span>
              </Button>
            )}
          </div>
        </div>
      </header>

      {/* Body */}
      <div className="flex flex-1 overflow-hidden relative">
        {sidebarOpen && (
          <div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={() => setSidebarOpen(false)} aria-label="Close sidebar" />
        )}

        <aside
          className={`
            bg-background border-r-3 border-black flex-shrink-0 transition-all duration-200 ease-in-out
            overflow-hidden z-50
            md:static md:translate-x-0
            ${sidebarOpen
              ? "fixed inset-y-0 left-0 w-64 translate-x-0"
              : "fixed inset-y-0 left-0 w-64 -translate-x-full md:w-0"
            }
          `}
        >
          <div className="h-full overflow-y-auto">
            <div className="p-3 sm:p-4 space-y-4 sm:space-y-6">
              {/* Account Section */}
              <div className="space-y-3">
                <Text as="p" className="text-xs font-bold text-muted-foreground uppercase px-3 tracking-wider">
                  Account
                </Text>
                <nav className="space-y-1">
                  {accountGroup.children.map((item) => (
                    <NavGroup
                      key={item.key}
                      item={item}
                      depth={0}
                      isActive={isActive}
                      expandedKeys={expandedKeys}
                      onToggle={toggleGroup}
                      onNavigate={handleNavigate}
                    />
                  ))}
                </nav>
              </div>

              <div className="h-1 bg-black" />

              {/* Catalog Section */}
              <div className="space-y-3">
                <Text as="p" className="text-xs font-bold text-muted-foreground uppercase px-3 tracking-wider">
                  Catalog
                </Text>

                {!isEmbedded && (
                  <div className="px-3 space-y-2">
                    <Text as="label" className="text-xs font-medium text-foreground">Selected Store</Text>
                    {stores.length > 0 ? (
                      <Select value={activeShop || ""} onValueChange={setActiveShop} disabled={loading}>
                        <Select.Trigger className="w-full min-w-0 text-xs">
                          <span className="truncate lowercase">
                            <Select.Value placeholder={loading ? "Loading..." : "Select a store"} />
                          </span>
                        </Select.Trigger>
                        <Select.Content>
                          {stores.map((store) => (
                            <Select.Item key={store.shop} value={store.shop}>
                              {store.shopName || store.shop}
                            </Select.Item>
                          ))}
                        </Select.Content>
                      </Select>
                    ) : (
                      <Text as="p" className="text-sm text-muted-foreground italic">No stores connected</Text>
                    )}
                  </div>
                )}

                <nav className="space-y-1">
                  {catalogChildren.map((item) => (
                    <NavGroup
                      key={item.key}
                      item={item}
                      depth={0}
                      isActive={isActive}
                      expandedKeys={expandedKeys}
                      onToggle={toggleGroup}
                      onNavigate={handleNavigate}
                    />
                  ))}
                  <button
                    onClick={() => handleNavigate({ path: "/settings/catalog" })}
                    className="nav-link w-full text-sm nav-link-default"
                  >
                    + Add catalog
                  </button>
                </nav>
              </div>

              <div className="h-1 bg-black" />

              {/* Tools Section */}
              <div className="space-y-3">
                <Text as="p" className="text-xs font-bold text-muted-foreground uppercase px-3 tracking-wider">
                  Tools
                </Text>
                <nav className="space-y-1">
                  {toolsGroup.children.map((item) => (
                    <NavGroup
                      key={item.key}
                      item={item}
                      depth={0}
                      isActive={isActive}
                      expandedKeys={expandedKeys}
                      onToggle={toggleGroup}
                      onNavigate={handleNavigate}
                    />
                  ))}
                </nav>
              </div>
            </div>
          </div>
        </aside>

        <main className="flex-1 overflow-auto bg-background w-full">
          <div className="p-3 sm:p-4 md:p-6">{children}</div>
        </main>
      </div>

      {/* Coming Soon Dialog */}
      <Dialog.Root open={comingSoonDialogOpen} onOpenChange={setComingSoonDialogOpen}>
        <Dialog.Portal>
          <Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
          <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border-2 sm:border-4 border-black p-4 sm:p-6 w-[calc(100%-2rem)] sm:w-full max-w-md z-50 shadow-[4px_4px_0px_#000000] sm:shadow-[8px_8px_0px_#000000]">
            <Dialog.Title className="text-base sm:text-lg font-bold mb-2">Coming Soon</Dialog.Title>
            <Dialog.Description className="text-sm text-foreground mb-4">
              {comingSoonProduct} is currently under development and will be available in a future release.
            </Dialog.Description>
            <div className="flex justify-end mt-4">
              <Dialog.Close asChild>
                <Button variant="outline">Close</Button>
              </Dialog.Close>
            </div>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
}

Notes on what was deliberately dropped from the old implementation: the flat "Browse Catalog" nav button (superseded by expanding a game group), the separate "MTG Sync"/"Pokemon Sync"/"FAB Sync"/"Lorcana Sync" flat entries (superseded by per-game groups + the Catalog Settings toggle list), and ExclamationTriangleIcon/MagnifyingGlassIcon imports (no longer used). ListBulletIcon import is unused in this draft — remove it if lint flags it as unused (or wire it as the Queue nav item's icon by extending NavGroup's leaf rendering to accept an optional icon field; not required by any test above, so leave plain-text leaves as-is unless you choose to add icons).

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/RadixShell.test.jsx Expected: PASS

  • [ ] Step 5: Lint

Run: npm run lint -- client/src/components/RadixShell.jsx Expected: no errors (remove any unused imports flagged, e.g. ListBulletIcon if you didn't wire it up).

  • [ ] Step 6: Commit
bash
git add client/src/components/RadixShell.jsx client/src/components/RadixShell.test.jsx
git commit -m "feat(client): restructure sidebar into collapsible Account/Catalog/Tools nav tree"

Task 7: Settings — SettingsLayout + GeneralSettings

Files:

  • Create: client/src/pages/settings/SettingsLayout.jsx
  • Create: client/src/pages/settings/GeneralSettings.jsx
  • Test: client/src/pages/settings/GeneralSettings.test.jsx

Interfaces:

  • Consumes: SubNavTabs (Task 3), existing NotificationSettings, SalesChannelSettings, DeleteAllProducts components (no prop changes — relocated verbatim from Home.jsx's SettingsTab, lines 1084-1108, minus SetupMetafields which moves to Task 22 instead).

  • Produces: <SettingsLayout isEmbedded /> renders <SubNavTabs items={[General, Catalog, Billing]} /> + <Outlet/>. <GeneralSettings/> renders the three relocated cards.

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/settings/GeneralSettings.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../components/NotificationSettings', () => ({ default: () => <div>notification-settings</div> }));
vi.mock('../../components/SalesChannelSettings', () => ({ default: () => <div>sales-channel-settings</div> }));
vi.mock('../../components/DeleteAllProducts', () => ({ default: () => <div>delete-all-products</div> }));

import GeneralSettings from './GeneralSettings';

describe('GeneralSettings', () => {
  it('renders notification, sales channel, and danger zone sections', () => {
    render(<GeneralSettings />);
    expect(screen.getByText('notification-settings')).toBeInTheDocument();
    expect(screen.getByText('sales-channel-settings')).toBeInTheDocument();
    expect(screen.getByText('delete-all-products')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/settings/GeneralSettings.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement GeneralSettings.jsx

Relocate the JSX from Home.jsx's SettingsTab (lines 1084-1108), dropping the SetupMetafields card (it moves to Task 22's CatalogSettingsMetafieldsPage) and the onStatusChange prop plumbing that only SetupMetafields needed:

jsx
// client/src/pages/settings/GeneralSettings.jsx
import NotificationSettings from '../../components/NotificationSettings';
import SalesChannelSettings from '../../components/SalesChannelSettings';
import DeleteAllProducts from '../../components/DeleteAllProducts';
import { Card } from '@/components/retroui';

export default function GeneralSettings() {
  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      <Card className="p-3 sm:p-4 md:p-5">
        <NotificationSettings />
      </Card>
      <Card className="p-3 sm:p-4 md:p-5">
        <SalesChannelSettings />
      </Card>
      <Card className="p-3 sm:p-4 md:p-5">
        <DeleteAllProducts />
      </Card>
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/settings/GeneralSettings.test.jsx Expected: PASS

  • [ ] Step 5: Implement SettingsLayout.jsx (no dedicated test — it's a thin composition covered by Task 23's routing tests)
jsx
// client/src/pages/settings/SettingsLayout.jsx
import { Outlet } from 'react-router-dom';
import SubNavTabs from '../../components/SubNavTabs';

const SETTINGS_TABS = [
  { label: 'General', path: '/settings/general' },
  { label: 'Catalog', path: '/settings/catalog' },
  { label: 'Billing', path: '/settings/billing' },
];

export default function SettingsLayout({ isEmbedded = false }) {
  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="space-y-1 max-w-5xl mx-auto">
        <h1 className="text-2xl sm:text-3xl font-bold">Store Settings</h1>
      </div>
      <div className="max-w-5xl mx-auto">
        <SubNavTabs items={SETTINGS_TABS} isEmbedded={isEmbedded} />
      </div>
      <Outlet />
    </div>
  );
}
  • [ ] Step 6: Commit
bash
git add client/src/pages/settings/SettingsLayout.jsx client/src/pages/settings/GeneralSettings.jsx client/src/pages/settings/GeneralSettings.test.jsx
git commit -m "feat(client): add Settings layout and General settings page"

Task 8: Settings — CatalogSettings page

Files:

  • Create: client/src/pages/settings/CatalogSettings.jsx
  • Test: client/src/pages/settings/CatalogSettings.test.jsx

Interfaces:

  • Consumes: api.get('/games'), api.get('/preferences'), api.put('/preferences', { enabledCatalogs }) (Task 1), useStore().refreshPreferences (Task 5).

  • Produces: toggle list — real toggle per game from /api/games, static coming-soon rows for FAB/Lorcana/board games/supplies.

  • [ ] Step 1: Write the failing tests

jsx
// client/src/pages/settings/CatalogSettings.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../utils/api', () => ({
  default: { get: vi.fn(), put: vi.fn() },
}));
vi.mock('../../context/StoreContext', () => ({
  useStore: () => ({ refreshPreferences: vi.fn() }),
}));

import api from '../../utils/api';
import CatalogSettings from './CatalogSettings';

describe('CatalogSettings', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockImplementation((url) => {
      if (url === '/games') {
        return Promise.resolve({ data: { games: [
          { id: 'mtg', name: 'Magic: The Gathering' },
          { id: 'pokemon', name: 'Pokemon' },
          { id: 'riftbound', name: 'Riftbound' },
        ] } });
      }
      if (url === '/preferences') {
        return Promise.resolve({ data: { enabledCatalogs: ['mtg', 'pokemon'] } });
      }
      return Promise.resolve({ data: {} });
    });
    api.put.mockResolvedValue({ data: { success: true } });
  });

  it('renders a real toggle per game, checked according to enabledCatalogs', async () => {
    render(<CatalogSettings />);
    await waitFor(() => expect(screen.getByLabelText('Magic: The Gathering')).toBeInTheDocument());
    expect(screen.getByLabelText('Magic: The Gathering')).toBeChecked();
    expect(screen.getByLabelText('Pokemon')).toBeChecked();
    expect(screen.getByLabelText('Riftbound')).not.toBeChecked();
  });

  it('renders coming-soon rows that are not toggleable', async () => {
    render(<CatalogSettings />);
    await waitFor(() => screen.getByText('Flesh and Blood'));
    expect(screen.getByText('Flesh and Blood').closest('div')).toHaveTextContent('Coming Soon');
    expect(screen.queryByLabelText('Flesh and Blood')).not.toBeInTheDocument();
  });

  it('saves the updated enabledCatalogs list when a toggle is flipped', async () => {
    render(<CatalogSettings />);
    await waitFor(() => screen.getByLabelText('Riftbound'));
    fireEvent.click(screen.getByLabelText('Riftbound'));
    await waitFor(() => {
      expect(api.put).toHaveBeenCalledWith('/preferences', { enabledCatalogs: expect.arrayContaining(['mtg', 'pokemon', 'riftbound']) });
    });
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/settings/CatalogSettings.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/settings/CatalogSettings.jsx
import { useEffect, useState } from 'react';
import api from '../../utils/api';
import { useStore } from '../../context/StoreContext';
import { Badge, Card, Switch, Text } from '@/components/retroui';

const COMING_SOON_CATALOGS = ['Flesh and Blood', 'Lorcana', 'Board Games', 'Supplies'];

export default function CatalogSettings() {
  const { refreshPreferences } = useStore();
  const [games, setGames] = useState([]);
  const [enabledCatalogs, setEnabledCatalogs] = useState([]);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);

  useEffect(() => {
    let cancelled = false;
    Promise.all([api.get('/games'), api.get('/preferences')]).then(([gamesRes, prefsRes]) => {
      if (cancelled) return;
      setGames(gamesRes.data.games || []);
      setEnabledCatalogs(prefsRes.data.enabledCatalogs || []);
      setLoading(false);
    }).catch(() => {
      if (!cancelled) setLoading(false);
    });
    return () => { cancelled = true; };
  }, []);

  const toggleCatalog = async (gameId, checked) => {
    const next = checked
      ? [...new Set([...enabledCatalogs, gameId])]
      : enabledCatalogs.filter((id) => id !== gameId);

    setEnabledCatalogs(next);
    setSaving(true);
    try {
      await api.put('/preferences', { enabledCatalogs: next });
      await refreshPreferences();
    } catch (error) {
      console.error('Failed to update enabled catalogs:', error);
      setEnabledCatalogs(enabledCatalogs); // revert on failure
    } finally {
      setSaving(false);
    }
  };

  if (loading) {
    return <Text as="p" className="text-sm text-muted-foreground">Loading catalogs...</Text>;
  }

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      <Card className="p-3 sm:p-4 md:p-5">
        <div className="space-y-4">
          <div className="space-y-1">
            <Text as="h3" className="text-base sm:text-lg font-medium">Catalogs</Text>
            <Text as="p" className="text-sm text-muted-foreground">
              Toggle which catalogs appear in your sidebar. Turning a catalog off hides it — synced
              products, schedules, and settings are untouched and reappear when you turn it back on.
            </Text>
          </div>
          <div className="h-0.5 bg-black" />
          <div className="space-y-3">
            {games.map((game) => (
              <label key={game.id} className="flex items-center justify-between gap-3 py-1">
                <Text as="span" className="text-sm font-medium">{game.name}</Text>
                <Switch
                  aria-label={game.name}
                  checked={enabledCatalogs.includes(game.id)}
                  onCheckedChange={(checked) => toggleCatalog(game.id, checked)}
                  disabled={saving}
                />
              </label>
            ))}
            {COMING_SOON_CATALOGS.map((label) => (
              <div key={label} className="flex items-center justify-between gap-3 py-1 opacity-60">
                <Text as="span" className="text-sm font-medium">{label}</Text>
                <Badge variant="info">Coming Soon</Badge>
              </div>
            ))}
          </div>
        </div>
      </Card>
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/settings/CatalogSettings.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/settings/CatalogSettings.jsx client/src/pages/settings/CatalogSettings.test.jsx
git commit -m "feat(client): add Settings > Catalog toggle page"

Task 9: Settings — BillingSettings page

Files:

  • Create: client/src/pages/settings/BillingSettings.jsx
  • Test: client/src/pages/settings/BillingSettings.test.jsx

Interfaces:

  • Consumes: existing BillingStatusBanner component (shop prop, unchanged), useStore().activeShop.

  • Produces: current plan/usage card + a "Change Plan" link to Shopify's managed-pricing page.

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/settings/BillingSettings.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../components/BillingStatusBanner', () => ({
  default: ({ shop }) => <div>billing-status-banner:{shop}</div>,
}));
vi.mock('../../context/StoreContext', () => ({
  useStore: () => ({ activeShop: 'test.myshopify.com' }),
}));

import BillingSettings from './BillingSettings';

describe('BillingSettings', () => {
  it('renders the billing status banner scoped to the active shop', () => {
    render(<BillingSettings />);
    expect(screen.getByText('billing-status-banner:test.myshopify.com')).toBeInTheDocument();
  });

  it('renders a change-plan link', () => {
    render(<BillingSettings />);
    expect(screen.getByRole('link', { name: /change plan/i })).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/settings/BillingSettings.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/settings/BillingSettings.jsx
import BillingStatusBanner from '../../components/BillingStatusBanner';
import { useStore } from '../../context/StoreContext';
import { Card, Text } from '@/components/retroui';

export default function BillingSettings() {
  const { activeShop } = useStore();

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      <Card className="p-3 sm:p-4 md:p-5">
        <div className="space-y-4">
          <div className="space-y-1">
            <Text as="h3" className="text-base sm:text-lg font-medium">Plan & Usage</Text>
          </div>
          <div className="h-0.5 bg-black" />
          <BillingStatusBanner shop={activeShop} />
          <a
            href={`https://admin.shopify.com/store/${(activeShop || '').replace('.myshopify.com', '')}/charges/lgs-forge/pricing_plans`}
            target="_blank"
            rel="noopener noreferrer"
            className="text-sm font-medium underline"
          >
            Change Plan
          </a>
        </div>
      </Card>
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/settings/BillingSettings.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/settings/BillingSettings.jsx client/src/pages/settings/BillingSettings.test.jsx
git commit -m "feat(client): add Settings > Billing page"

Task 10: Queue page

Files:

  • Create: client/src/pages/Queue.jsx
  • Test: client/src/pages/Queue.test.jsx

Interfaces:

  • Consumes: existing SyncHistory component (limit prop, unchanged) — it already provides search, job-type filter, tabs, and the detail modal, so this page is a thin wrapper (per the pre-plan audit finding that /api/sync/history without a jobType filter already returns both job types merged).

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/Queue.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../components/SyncHistory', () => ({
  default: ({ limit }) => <div>sync-history:{limit}</div>,
}));

import Queue from './Queue';

describe('Queue', () => {
  it('renders SyncHistory with a generous limit', () => {
    render(<Queue />);
    expect(screen.getByText('sync-history:50')).toBeInTheDocument();
  });

  it('renders a page header', () => {
    render(<Queue />);
    expect(screen.getByRole('heading', { name: /queue/i })).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/Queue.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/Queue.jsx
import SyncHistory from '../components/SyncHistory';
import { Text } from '@/components/retroui';

export default function Queue() {
  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="space-y-1">
        <Text as="h1" className="text-2xl sm:text-3xl font-bold">Queue</Text>
        <Text as="p" className="text-muted-foreground">
          All product syncs and price updates across every catalog.
        </Text>
      </div>
      <SyncHistory limit={50} />
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/Queue.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/Queue.jsx client/src/pages/Queue.test.jsx
git commit -m "feat(client): add unified Queue page"

Task 11: Dashboard — StoreInfoCard (+ VariantBudgetCard bug fix)

Files:

  • Create: client/src/components/dashboard/StoreInfoCard.jsx
  • Modify: client/src/components/VariantBudgetCard.jsx:11
  • Test: client/src/components/dashboard/StoreInfoCard.test.jsx
  • Test: client/src/components/VariantBudgetCard.test.jsx

Interfaces:

  • Consumes: billingApi.getStatus(shop), api.get('/sync/history', {params:{limit:1}}), api.get('/sync/history', {params:{limit:1, jobType:'price_update'}}), existing VariantBudgetCard (no prop changes, just its internal bug fix).

  • Produces: <StoreInfoCard shop={shop} isEmbedded /> — plan/usage row, last product sync row, last price sync row, each with independent loading/error state.

  • [ ] Step 1: Write the failing test for the bug fix

jsx
// client/src/components/VariantBudgetCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../utils/api', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: { capped: false, reason: 'plus' } })) } }));

import api from '../utils/api';
import VariantBudgetCard from './VariantBudgetCard';

describe('VariantBudgetCard', () => {
  it('requests /variant-budget without double-prefixing /api', async () => {
    render(<VariantBudgetCard />);
    await waitFor(() => expect(api.get).toHaveBeenCalledWith('/variant-budget'));
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/VariantBudgetCard.test.jsx Expected: FAIL — currently calls api.get('/api/variant-budget').

  • [ ] Step 3: Fix the bug

In client/src/components/VariantBudgetCard.jsx:11, change:

js
    api.get('/api/variant-budget')

to:

js
    api.get('/variant-budget')
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/VariantBudgetCard.test.jsx Expected: PASS

  • [ ] Step 5: Write the failing StoreInfoCard tests
jsx
// client/src/components/dashboard/StoreInfoCard.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../utils/api', () => ({
  default: { get: vi.fn() },
  billingApi: { getStatus: vi.fn() },
}));
vi.mock('../VariantBudgetCard', () => ({ default: () => <div>variant-budget-card</div> }));

import api, { billingApi } from '../../utils/api';
import StoreInfoCard from './StoreInfoCard';

describe('StoreInfoCard', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    billingApi.getStatus.mockResolvedValue({ data: { tierName: 'Starter', productLimit: 2000, productUsage: { current: 150 } } });
    api.get.mockImplementation((url, config) => {
      if (config?.params?.jobType === 'price_update') {
        return Promise.resolve({ data: { jobs: [{ createdAt: '2026-07-08T10:00:00Z' }] } });
      }
      return Promise.resolve({ data: { jobs: [{ createdAt: '2026-07-09T10:00:00Z' }] } });
    });
  });

  it('shows plan name and product usage', async () => {
    render(<StoreInfoCard shop="test.myshopify.com" isEmbedded={true} />);
    await waitFor(() => expect(screen.getByText(/Starter/)).toBeInTheDocument());
    expect(screen.getByText(/150/)).toBeInTheDocument();
  });

  it('shows last product sync and last price sync independently', async () => {
    render(<StoreInfoCard shop="test.myshopify.com" isEmbedded={true} />);
    await waitFor(() => expect(screen.getByText(/Last product sync/i)).toBeInTheDocument());
    expect(screen.getByText(/Last price sync/i)).toBeInTheDocument();
  });

  it('renders the embedded-only VariantBudgetCard', async () => {
    render(<StoreInfoCard shop="test.myshopify.com" isEmbedded={true} />);
    await waitFor(() => expect(screen.getByText('variant-budget-card')).toBeInTheDocument());
  });

  it('does not blank the whole card if billing status fails', async () => {
    billingApi.getStatus.mockRejectedValue(new Error('boom'));
    render(<StoreInfoCard shop="test.myshopify.com" isEmbedded={true} />);
    await waitFor(() => expect(screen.getByText(/Last product sync/i)).toBeInTheDocument());
  });
});
  • [ ] Step 6: Run to verify it fails

Run: npm run test:client -- client/src/components/dashboard/StoreInfoCard.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 7: Implement
jsx
// client/src/components/dashboard/StoreInfoCard.jsx
import { useEffect, useState } from 'react';
import api, { billingApi } from '../../utils/api';
import VariantBudgetCard from '../VariantBudgetCard';
import { Card, Text } from '@/components/retroui';

function formatDate(dateString) {
  if (!dateString) return 'Never';
  return new Date(dateString).toLocaleString();
}

export default function StoreInfoCard({ shop, isEmbedded }) {
  const [billing, setBilling] = useState(null);
  const [billingError, setBillingError] = useState(false);
  const [lastProductSync, setLastProductSync] = useState(undefined);
  const [lastPriceSync, setLastPriceSync] = useState(undefined);

  useEffect(() => {
    if (!shop) return;
    billingApi.getStatus(shop).then(({ data }) => setBilling(data)).catch(() => setBillingError(true));
    api.get('/sync/history', { params: { limit: 1 } })
      .then(({ data }) => setLastProductSync(data.jobs?.[0]?.createdAt || null))
      .catch(() => setLastProductSync(null));
    api.get('/sync/history', { params: { limit: 1, jobType: 'price_update' } })
      .then(({ data }) => setLastPriceSync(data.jobs?.[0]?.createdAt || null))
      .catch(() => setLastPriceSync(null));
  }, [shop]);

  return (
    <Card className="p-4 sm:p-5">
      <div className="space-y-4">
        <Text as="h3" className="text-lg font-medium">Store Info</Text>
        <div className="h-0.5 bg-black" />

        <div className="space-y-1">
          {billingError ? (
            <Text as="p" className="text-sm text-muted-foreground">Plan info unavailable.</Text>
          ) : billing ? (
            <Text as="p" className="text-sm">
              <strong>{billing.tierName}</strong> plan
              {typeof billing.productUsage?.current === 'number' && typeof billing.productLimit === 'number' && (
                <> — {billing.productUsage.current.toLocaleString()} / {billing.productLimit.toLocaleString()} products</>
              )}
            </Text>
          ) : (
            <Text as="p" className="text-sm text-muted-foreground">Loading plan...</Text>
          )}
        </div>

        <div className="space-y-1">
          <Text as="p" className="text-sm">
            Last product sync: {lastProductSync === undefined ? 'Loading...' : formatDate(lastProductSync)}
          </Text>
          <Text as="p" className="text-sm">
            Last price sync: {lastPriceSync === undefined ? 'Loading...' : formatDate(lastPriceSync)}
          </Text>
        </div>

        {isEmbedded && <VariantBudgetCard />}
      </div>
    </Card>
  );
}
  • [ ] Step 8: Run to verify it passes

Run: npm run test:client -- client/src/components/dashboard/StoreInfoCard.test.jsx Expected: PASS

  • [ ] Step 9: Commit
bash
git add client/src/components/VariantBudgetCard.jsx client/src/components/VariantBudgetCard.test.jsx client/src/components/dashboard/StoreInfoCard.jsx client/src/components/dashboard/StoreInfoCard.test.jsx
git commit -m "fix(client): stop double-prefixing /api/variant-budget; add Dashboard Store Info card"

Task 12: Dashboard — AnnouncementsCard

Files:

  • Create: client/src/components/dashboard/AnnouncementsCard.jsx
  • Test: client/src/components/dashboard/AnnouncementsCard.test.jsx

Interfaces:

  • Produces: static placeholder card, no API calls, no props.

  • [ ] Step 1: Write the failing test

jsx
// client/src/components/dashboard/AnnouncementsCard.test.jsx
import { describe, it, expect } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import AnnouncementsCard from './AnnouncementsCard';

describe('AnnouncementsCard', () => {
  it('renders the caught-up placeholder', () => {
    render(<AnnouncementsCard />);
    expect(screen.getByText(/all caught up/i)).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/dashboard/AnnouncementsCard.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/components/dashboard/AnnouncementsCard.jsx
import { Card, Text } from '@/components/retroui';

export default function AnnouncementsCard() {
  return (
    <Card className="p-4 sm:p-5">
      <div className="space-y-2">
        <Text as="h3" className="text-lg font-medium">Announcements</Text>
        <div className="h-0.5 bg-black" />
        <Text as="p" className="text-sm text-muted-foreground">You're all caught up.</Text>
      </div>
    </Card>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/dashboard/AnnouncementsCard.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/components/dashboard/AnnouncementsCard.jsx client/src/components/dashboard/AnnouncementsCard.test.jsx
git commit -m "feat(client): add Dashboard announcements placeholder card"

Task 13: Dashboard — ActivityCard

Files:

  • Create: client/src/components/dashboard/ActivityCard.jsx
  • Test: client/src/components/dashboard/ActivityCard.test.jsx

Interfaces:

  • Consumes: api.get('/sync/history', {params:{limit:5}}) (unfiltered — already returns both job types merged, sorted createdAt desc, per the pre-plan audit), getJobTitle/getStatusBadgeConfig/formatDuration from ../../utils/jobHelpers, existing SyncJobDetailModal (jobId, open, onClose, onJobCancelled — no prop changes).

  • Produces: <ActivityCard isEmbedded /> — up to 5 rows, row click opens the modal, "View all" navigates to /queue (shop-aware).

  • [ ] Step 1: Write the failing tests

jsx
// client/src/components/dashboard/ActivityCard.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn() } }));
vi.mock('../SyncJobDetailModal', () => ({
  default: ({ jobId, open }) => (open ? <div>modal-open:{jobId}</div> : null),
}));

import api from '../../utils/api';
import ActivityCard from './ActivityCard';

describe('ActivityCard', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({
      data: {
        jobs: [
          { _id: 'job1', jobType: 'product_sync', status: 'completed', createdAt: '2026-07-09T10:00:00Z' },
          { _id: 'job2', jobType: 'price_update', status: 'failed', createdAt: '2026-07-08T10:00:00Z' },
        ],
      },
    });
  });

  it('fetches at most 5 recent jobs from the unfiltered history endpoint', async () => {
    render(<MemoryRouter><ActivityCard /></MemoryRouter>);
    await waitFor(() => {
      expect(api.get).toHaveBeenCalledWith('/sync/history', { params: { limit: 5 } });
    });
  });

  it('opens the detail modal when a row is clicked', async () => {
    render(<MemoryRouter><ActivityCard /></MemoryRouter>);
    await waitFor(() => screen.getAllByRole('button', { name: /job1|job2|product_sync|price_update/i }));
    const rows = screen.getAllByTestId('activity-row');
    fireEvent.click(rows[0]);
    expect(screen.getByText('modal-open:job1')).toBeInTheDocument();
  });

  it('renders a View all link to /queue', async () => {
    render(<MemoryRouter><ActivityCard /></MemoryRouter>);
    await waitFor(() => screen.getByText(/view all/i));
    expect(screen.getByRole('link', { name: /view all/i })).toHaveAttribute('href', '/queue');
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/dashboard/ActivityCard.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/components/dashboard/ActivityCard.jsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../../utils/api';
import SyncJobDetailModal from '../SyncJobDetailModal';
import { getJobTitle, getStatusBadgeConfig } from '../../utils/jobHelpers';
import { Badge, Card, Text } from '@/components/retroui';

export default function ActivityCard() {
  const [jobs, setJobs] = useState([]);
  const [loading, setLoading] = useState(true);
  const [selectedJobId, setSelectedJobId] = useState(null);
  const [modalOpen, setModalOpen] = useState(false);

  useEffect(() => {
    let cancelled = false;
    api.get('/sync/history', { params: { limit: 5 } })
      .then(({ data }) => { if (!cancelled) setJobs(data.jobs || []); })
      .catch(() => { if (!cancelled) setJobs([]); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, []);

  const openJob = (job) => {
    setSelectedJobId(job._id);
    setModalOpen(true);
  };

  return (
    <Card className="p-4 sm:p-5">
      <div className="space-y-3">
        <div className="flex items-center justify-between">
          <Text as="h3" className="text-lg font-medium">Activity</Text>
          <Link to="/queue" className="text-sm font-medium underline">View all</Link>
        </div>
        <div className="h-0.5 bg-black" />

        {loading ? (
          <Text as="p" className="text-sm text-muted-foreground">Loading activity...</Text>
        ) : jobs.length === 0 ? (
          <Text as="p" className="text-sm text-muted-foreground">No sync activity yet.</Text>
        ) : (
          <div className="space-y-1">
            {jobs.map((job) => {
              const badge = getStatusBadgeConfig(job.status);
              return (
                <button
                  key={job._id}
                  data-testid="activity-row"
                  onClick={() => openJob(job)}
                  className="w-full flex items-center justify-between gap-2 py-2 text-left hover:bg-muted px-2 -mx-2"
                >
                  <Text as="span" className="text-sm truncate">{getJobTitle(job)}</Text>
                  <Badge variant={badge.variant}>{badge.label}</Badge>
                </button>
              );
            })}
          </div>
        )}
      </div>

      <SyncJobDetailModal
        jobId={selectedJobId}
        open={modalOpen}
        onClose={() => setModalOpen(false)}
        onJobCancelled={() => setModalOpen(false)}
      />
    </Card>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/components/dashboard/ActivityCard.test.jsx Expected: PASS (verify getStatusBadgeConfig's actual return shape in client/src/utils/jobHelpers.js:243 matches { variant, label } before relying on it — adjust the destructuring above to match if the real shape differs, e.g. it may return { variant, text }).

  • [ ] Step 5: Commit
bash
git add client/src/components/dashboard/ActivityCard.jsx client/src/components/dashboard/ActivityCard.test.jsx
git commit -m "feat(client): add Dashboard activity card with last 5 jobs"

Task 14: Dashboard.jsx rewrite

Files:

  • Modify: client/src/pages/Dashboard.jsx (full rewrite)
  • Test: client/src/pages/Dashboard.test.jsx

Interfaces:

  • Consumes: StoreInfoCard, AnnouncementsCard, ActivityCard (Tasks 11-13), existing ConnectedStores, AnalyticsDashboard, userApi, useStore().

  • Produces: fixes the isEmbedded inconsistency (derive from useStore()/props instead of a local !!searchParams.get('shop') recompute) and gates ConnectedStores behind !isEmbedded (a real behavior change flagged in the pre-plan audit).

  • [ ] Step 1: Write the failing tests

jsx
// client/src/pages/Dashboard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';

vi.mock('../components/dashboard/StoreInfoCard', () => ({ default: () => <div>store-info-card</div> }));
vi.mock('../components/dashboard/AnnouncementsCard', () => ({ default: () => <div>announcements-card</div> }));
vi.mock('../components/dashboard/ActivityCard', () => ({ default: () => <div>activity-card</div> }));
vi.mock('../components/ConnectedStores', () => ({ default: () => <div>connected-stores</div> }));
vi.mock('../components/AnalyticsDashboard', () => ({ default: () => <div>analytics-dashboard</div> }));
vi.mock('../utils/api', () => ({ userApi: { me: vi.fn(() => Promise.resolve({ data: { user: { connectedStores: [] } } })) }, setSelectedShop: vi.fn(), getSelectedShop: vi.fn() }));

function mockStore(overrides) {
  return { activeShop: 'test.myshopify.com', stores: [], ...overrides };
}

describe('Dashboard', () => {
  it('shows ConnectedStores in standalone mode', async () => {
    vi.doMock('../context/StoreContext', () => ({ useStore: () => mockStore() }));
    const { default: Dashboard } = await import('./Dashboard');
    render(<MemoryRouter initialEntries={['/dashboard']}><Dashboard /></MemoryRouter>);
    expect(await screen.findByText('connected-stores')).toBeInTheDocument();
  });

  it('hides ConnectedStores in embedded mode', async () => {
    vi.resetModules();
    vi.doMock('../context/StoreContext', () => ({ useStore: () => mockStore() }));
    vi.doMock('../utils/api', () => ({ userApi: { me: vi.fn() }, setSelectedShop: vi.fn(), getSelectedShop: vi.fn() }));
    const { default: Dashboard } = await import('./Dashboard');
    render(<MemoryRouter initialEntries={['/dashboard?shop=test.myshopify.com']}><Dashboard /></MemoryRouter>);
    expect(screen.queryByText('connected-stores')).not.toBeInTheDocument();
    expect(await screen.findByText('store-info-card')).toBeInTheDocument();
  });

  it('always renders the Store Overview cards', async () => {
    vi.resetModules();
    vi.doMock('../context/StoreContext', () => ({ useStore: () => mockStore() }));
    vi.doMock('../utils/api', () => ({ userApi: { me: vi.fn() }, setSelectedShop: vi.fn(), getSelectedShop: vi.fn() }));
    const { default: Dashboard } = await import('./Dashboard');
    render(<MemoryRouter initialEntries={['/dashboard?shop=test.myshopify.com']}><Dashboard /></MemoryRouter>);
    expect(await screen.findByText('store-info-card')).toBeInTheDocument();
    expect(screen.getByText('announcements-card')).toBeInTheDocument();
    expect(screen.getByText('activity-card')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/Dashboard.test.jsx Expected: FAIL — current Dashboard.jsx always renders ConnectedStores and has no StoreInfoCard/AnnouncementsCard/ActivityCard.

  • [ ] Step 3: Implement

Replace client/src/pages/Dashboard.jsx in full. This keeps the existing WelcomeBanner/ConnectionSuccessBanner/ErrorBanner local components and the standalone user-loading logic (lines 30-104, 217-292 of the old file) unchanged, drops the local SubscriptionCard/HelpCard (superseded by /settings/billing's BillingStatusBanner reuse and the docs links already available via the app's Help/docs surfaces — not reintroduced here since the spec doesn't ask for a Dashboard-embedded help card), and swaps the main grid for the new Store Overview composition:

jsx
/**
 * Dashboard Page — "Store Overview"
 *
 * Landing page for both embedded and standalone modes.
 */

import { useState, useEffect, useCallback } from "react";
import {
  CheckCircledIcon,
  ExclamationTriangleIcon,
  InfoCircledIcon,
  Cross2Icon,
} from "@radix-ui/react-icons";
import ConnectedStores from "../components/ConnectedStores";
import AnalyticsDashboard from "../components/AnalyticsDashboard";
import StoreInfoCard from "../components/dashboard/StoreInfoCard";
import AnnouncementsCard from "../components/dashboard/AnnouncementsCard";
import ActivityCard from "../components/dashboard/ActivityCard";
import { userApi, setSelectedShop, getSelectedShop } from "../utils/api";
import { useStore } from "../context/StoreContext";
import { Alert, Text } from "@/components/retroui";

function WelcomeBanner({ onDismiss }) {
  return (
    <Alert status="info">
      <div className="flex justify-between items-start">
        <div className="space-y-1">
          <Alert.Title className="flex items-center gap-2">
            <InfoCircledIcon className="w-4 h-4" />
            Welcome to LGS FORGE
          </Alert.Title>
          <Alert.Description>
            Sync collectible products directly to your Shopify store.
            Get started by connecting your first store below.
          </Alert.Description>
        </div>
        <button onClick={onDismiss} className="p-1 hover:bg-black/10 rounded">
          <Cross2Icon className="w-4 h-4" />
        </button>
      </div>
    </Alert>
  );
}

function ConnectionSuccessBanner({ shop, onDismiss }) {
  return (
    <Alert status="success">
      <div className="flex justify-between items-start">
        <div>
          <Alert.Title className="flex items-center gap-2">
            <CheckCircledIcon className="w-4 h-4" />
            Store connected successfully!
          </Alert.Title>
          <Alert.Description>
            {shop} is now connected. You can start syncing products right away.
          </Alert.Description>
        </div>
        <button onClick={onDismiss} className="p-1 hover:bg-black/10 rounded">
          <Cross2Icon className="w-4 h-4" />
        </button>
      </div>
    </Alert>
  );
}

function ErrorBanner({ error, onDismiss }) {
  const errorMessages = {
    connect_failed: "Failed to start the connection. Please try again.",
    invalid_signature: "Security verification failed. Please try again.",
    invalid_state: "Session expired. Please try connecting again.",
    token_failed: "Failed to get authorization from Shopify. Please try again.",
    connection_failed: "Connection failed. Please try again or contact support.",
  };

  return (
    <Alert status="error">
      <div className="flex justify-between items-start">
        <div>
          <Alert.Title className="flex items-center gap-2">
            <ExclamationTriangleIcon className="w-4 h-4" />
            Connection Error
          </Alert.Title>
          <Alert.Description>{errorMessages[error] || "An unexpected error occurred."}</Alert.Description>
        </div>
        <button onClick={onDismiss} className="p-1 hover:bg-black/10 rounded">
          <Cross2Icon className="w-4 h-4" />
        </button>
      </div>
    </Alert>
  );
}

export default function Dashboard({ isEmbedded = false }) {
  const storeContext = useStore();

  const [loading, setLoading] = useState(!isEmbedded);
  const [user, setUser] = useState(null);
  const [stores, setStores] = useState([]);
  const [selectedStore, setSelectedStore] = useState(null);
  const [showWelcome, setShowWelcome] = useState(false);
  const [successMessage, setSuccessMessage] = useState(null);
  const [errorMessage, setErrorMessage] = useState(null);

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const connected = params.get("connected");
    const error = params.get("error");

    if (connected) {
      setSuccessMessage(connected);
      window.history.replaceState({}, "", "/dashboard");
    }
    if (error) {
      setErrorMessage(error);
      window.history.replaceState({}, "", "/dashboard");
    }
  }, []);

  useEffect(() => {
    if (isEmbedded) {
      setStores(storeContext.stores);
      setSelectedStore(storeContext.activeShop);
      setLoading(false);
    }
  }, [isEmbedded, storeContext.stores, storeContext.activeShop]);

  useEffect(() => {
    if (!isEmbedded) loadUserData();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isEmbedded]);

  const loadUserData = async () => {
    try {
      setLoading(true);
      const { data } = await userApi.me();
      if (data.user) {
        setUser(data.user);
        setStores(data.user.connectedStores || []);
        if (data.user.connectedStores?.length > 0) {
          const previouslySelected = getSelectedShop();
          const isStillConnected = data.user.connectedStores.some((s) => s.shop === previouslySelected);
          const shopToSelect = isStillConnected ? previouslySelected : data.user.connectedStores[0].shop;
          setSelectedStore(shopToSelect);
          setSelectedShop(shopToSelect);
        } else {
          setSelectedShop(null);
        }
        if (data.user.connectedStores?.length === 0) setShowWelcome(true);
      }
    } catch (error) {
      console.error("Failed to load user data:", error);
      setShowWelcome(true);
    } finally {
      setLoading(false);
    }
  };

  const handleConnectStore = useCallback(() => {}, []);

  const handleDisconnectStore = useCallback(async (shop) => {
    try {
      await userApi.disconnectStore(shop);
      setStores((prev) => prev.filter((s) => s.shop !== shop));
      if (selectedStore === shop) {
        const remaining = stores.filter((s) => s.shop !== shop);
        const newSelected = remaining.length > 0 ? remaining[0].shop : null;
        setSelectedStore(newSelected);
        setSelectedShop(newSelected);
      }
    } catch (error) {
      console.error("Failed to disconnect store:", error);
    }
  }, [selectedStore, stores]);

  if (loading) {
    return (
      <div className="space-y-6">
        <div className="space-y-2">
          <div className="h-8 w-48 bg-muted animate-pulse rounded" />
          <div className="h-4 w-64 bg-muted animate-pulse rounded" />
        </div>
      </div>
    );
  }

  const activeShop = isEmbedded ? storeContext.activeShop : selectedStore;

  return (
    <div className="space-y-6">
      <div className="space-y-1">
        <Text as="h1" className="text-3xl font-bold">Store Overview</Text>
        <Text as="p" className="text-muted-foreground">
          Managing {activeShop || "your store"}
        </Text>
      </div>

      {!isEmbedded && (
        <div className="space-y-4">
          {showWelcome && stores.length === 0 && <WelcomeBanner onDismiss={() => setShowWelcome(false)} />}
          {successMessage && <ConnectionSuccessBanner shop={successMessage} onDismiss={() => setSuccessMessage(null)} />}
          {errorMessage && <ErrorBanner error={errorMessage} onDismiss={() => setErrorMessage(null)} />}
        </div>
      )}

      {!isEmbedded && (
        <ConnectedStores
          stores={stores}
          onConnect={handleConnectStore}
          onDisconnect={handleDisconnectStore}
          isEmbedded={false}
        />
      )}

      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
        <StoreInfoCard shop={activeShop} isEmbedded={isEmbedded} />
        <AnnouncementsCard />
        <ActivityCard />
      </div>

      <AnalyticsDashboard />
    </div>
  );
}

isEmbedded is now a prop passed down from App.jsx's routing (Task 23), not recomputed locally from searchParams — this is the fix for the flagged inconsistency versus useIsEmbedded()/RadixShell.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/Dashboard.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/Dashboard.jsx client/src/pages/Dashboard.test.jsx
git commit -m "feat(client): rebuild Dashboard as Store Overview, fix isEmbedded prop-drilling"

Task 15: Catalog — CatalogGameLayout

Files:

  • Create: client/src/pages/catalog/CatalogGameLayout.jsx
  • Test: client/src/pages/catalog/CatalogGameLayout.test.jsx

Interfaces:

  • Consumes: api.get('/games'), useParams().game, SubNavTabs (Task 3).

  • Produces: validates :game against /api/games; if unknown, redirects to /dashboard; otherwise renders the Singles/Sealed/Sets/Settings sub-nav + <Outlet/>.

  • [ ] Step 1: Write the failing tests

jsx
// client/src/pages/catalog/CatalogGameLayout.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('../../utils/api', () => ({
  default: { get: vi.fn(() => Promise.resolve({ data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } })) },
}));

import CatalogGameLayout from './CatalogGameLayout';

function renderAt(path) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="/catalog/:game" element={<CatalogGameLayout />}>
          <Route path="singles" element={<div>singles-content</div>} />
        </Route>
        <Route path="/dashboard" element={<div>dashboard-page</div>} />
      </Routes>
    </MemoryRouter>
  );
}

describe('CatalogGameLayout', () => {
  beforeEach(() => vi.clearAllMocks());

  it('renders the sub-nav and outlet for a known game', async () => {
    renderAt('/catalog/mtg/singles');
    await waitFor(() => expect(screen.getByText('singles-content')).toBeInTheDocument());
    expect(screen.getByRole('button', { name: 'Singles' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Sealed' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Sets' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
  });

  it('redirects to /dashboard for an unknown game', async () => {
    renderAt('/catalog/lorcana/singles');
    await waitFor(() => expect(screen.getByText('dashboard-page')).toBeInTheDocument());
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogGameLayout.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/catalog/CatalogGameLayout.jsx
import { useEffect, useState } from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import api from '../../utils/api';
import SubNavTabs from '../../components/SubNavTabs';
import { Text } from '@/components/retroui';

export default function CatalogGameLayout({ isEmbedded = false }) {
  const { game } = useParams();
  const [games, setGames] = useState(null);

  useEffect(() => {
    let cancelled = false;
    api.get('/games').then(({ data }) => {
      if (!cancelled) setGames(data.games || []);
    }).catch(() => {
      if (!cancelled) setGames([]);
    });
    return () => { cancelled = true; };
  }, []);

  if (games === null) {
    return <Text as="p" className="text-sm text-muted-foreground">Loading...</Text>;
  }

  const gameInfo = games.find((g) => g.id === game);
  if (!gameInfo) {
    return <Navigate to="/dashboard" replace />;
  }

  const tabs = [
    { label: 'Singles', path: `/catalog/${game}/singles` },
    { label: 'Sealed', path: `/catalog/${game}/sealed` },
    { label: 'Sets', path: `/catalog/${game}/sets` },
    { label: 'Settings', path: `/catalog/${game}/settings/pricing` },
  ];

  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="space-y-1 max-w-5xl mx-auto">
        <Text as="h1" className="text-2xl sm:text-3xl font-bold">{gameInfo.name}</Text>
      </div>
      <div className="max-w-5xl mx-auto">
        <SubNavTabs items={tabs} isEmbedded={isEmbedded} />
      </div>
      <Outlet />
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogGameLayout.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogGameLayout.jsx client/src/pages/catalog/CatalogGameLayout.test.jsx
git commit -m "feat(client): add per-game catalog layout with game validation"

Task 16: Catalog — CatalogSinglesPage

Files:

  • Create: client/src/pages/catalog/CatalogSinglesPage.jsx
  • Test: client/src/pages/catalog/CatalogSinglesPage.test.jsx

Interfaces:

  • Consumes: useParams().game (replaces the selectedGame prop), existing CardImage, PriceHistoryModal, CardSyncControls, useCardSyncAction/isSyncableNumber from ../../hooks/useCardSyncAction, formatPrice/buildPriceTooltip from ../../utils/format, getRarityOptions/getCardTypeOptions.

  • Produces: identical behavior to the old CatalogCardsView + CardSetPicker from CatalogTab.jsx, now selectedSets is page-local state (no cross-route sharing — see Global Constraints).

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogSinglesPage.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn() } }));

import api from '../../utils/api';
import CatalogSinglesPage from './CatalogSinglesPage';

function renderAt(game) {
  return render(
    <MemoryRouter initialEntries={[`/catalog/${game}/singles`]}>
      <Routes>
        <Route path="/catalog/:game/singles" element={<CatalogSinglesPage />} />
      </Routes>
    </MemoryRouter>
  );
}

describe('CatalogSinglesPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({ data: { cards: [], pagination: { page: 1, pages: 1, total: 0 } } });
  });

  it('loads cards scoped to the :game route param', async () => {
    renderAt('pokemon');
    await waitFor(() => {
      expect(api.get).toHaveBeenCalledWith('/catalog/cards', expect.objectContaining({ params: expect.objectContaining({ game: 'pokemon' }) }));
    });
  });

  it('renders the empty state when no cards are found', async () => {
    renderAt('mtg');
    await waitFor(() => expect(screen.getByText('No singles found.')).toBeInTheDocument());
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement

Create the file with the exact contents of CatalogCardsView and CardSetPicker cut verbatim from client/src/components/CatalogTab.jsx:612-1074 (the CardSetPicker function and everything from function CatalogCardsView({ selectedSets, onSelectedSetsChange, selectedGame }) through its closing brace, including the module-level SET_SEARCH_DEBOUNCE_MS constant and all imports those two functions use: useEffect, useRef, useState from 'react'; Badge, Button, Card, MultiSelectCombobox, Text from '@/components/retroui'; MagnifyingGlassIcon, Cross2Icon from '@radix-ui/react-icons'; TrendingUp from 'lucide-react'; CardImage from '../../components/CardImage'; PriceHistoryModal from '../../components/PriceHistoryModal'; CardSyncControls from '../../components/CardSyncControls'; useCardSyncAction, { isSyncableNumber } from '../../hooks/useCardSyncAction'; api from '../../utils/api'; formatPrice, buildPriceTooltip from '../../utils/format'; getRarityOptions from '../../lib/rarityOptions'; getCardTypeOptions from '../../lib/cardTypeOptions' (note the extra ../ on every relative import since this file now lives one directory deeper, in pages/catalog/ instead of components/).

Then apply this diff to the pasted code:

  1. Add import { useParams } from 'react-router-dom'; to the import block.
  2. Wrap the relocated CatalogCardsView function as the page's default export, sourcing selectedGame from the route instead of a prop, and owning its own selectedSets state (previously lifted to CatalogTab's parent):
jsx
export default function CatalogSinglesPage() {
  const { game: selectedGame } = useParams();
  const [selectedSets, setSelectedSets] = useState([]);
  return (
    <CatalogCardsView
      selectedSets={selectedSets}
      onSelectedSetsChange={setSelectedSets}
      selectedGame={selectedGame}
    />
  );
}
  1. Rename the original function CatalogCardsView({ selectedSets, onSelectedSetsChange, selectedGame }) { ... } to a non-exported local function (drop any export keyword it doesn't have — it already isn't exported in the source, so no change needed there) and keep it below the new default-exported wrapper.
  2. Delete the trailing export default CatalogTab;-style line if present (there isn't one on this excerpt — CatalogCardsView had no export default in the original file, so nothing to remove).
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: PASS

  • [ ] Step 5: Lint

Run: npm run lint -- client/src/pages/catalog/CatalogSinglesPage.jsx Expected: no errors (fix any import path typos from the ../ adjustment).

  • [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogSinglesPage.jsx client/src/pages/catalog/CatalogSinglesPage.test.jsx
git commit -m "feat(client): add per-game Singles page (relocated from CatalogTab)"

Task 17: Catalog — CatalogSealedPage

Files:

  • Create: client/src/pages/catalog/CatalogSealedPage.jsx
  • Test: client/src/pages/catalog/CatalogSealedPage.test.jsx

Interfaces:

  • Consumes: useParams().game, api.

  • Produces: identical behavior to the old CatalogSealedView + SealedProductsGridView + SealedProductCard + SealedProductsListView from CatalogTab.jsx, selectedSets now local (always [], no picker UI existed for it before either — see Global Constraints).

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogSealedPage.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn() } }));

import api from '../../utils/api';
import CatalogSealedPage from './CatalogSealedPage';

function renderAt(game) {
  return render(
    <MemoryRouter initialEntries={[`/catalog/${game}/sealed`]}>
      <Routes>
        <Route path="/catalog/:game/sealed" element={<CatalogSealedPage />} />
      </Routes>
    </MemoryRouter>
  );
}

describe('CatalogSealedPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({ data: { sealedProducts: [], pagination: { page: 1, pages: 1, total: 0 } } });
  });

  it('loads sealed products scoped to the :game route param', async () => {
    renderAt('mtg');
    await waitFor(() => {
      expect(api.get).toHaveBeenCalledWith('/catalog/sealed', expect.objectContaining({ params: expect.objectContaining({ game: 'mtg' }) }));
    });
  });

  it('shows the not-yet-available message for a game without sealed support', async () => {
    renderAt('pokemon');
    await waitFor(() => expect(screen.getByText(/not yet available for this game/i)).toBeInTheDocument());
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSealedPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement

Create the file with the exact contents cut verbatim from client/src/components/CatalogTab.jsx:120-610 (CatalogSealedView, SealedProductsGridView, SealedProductCard, SealedProductsListView), with these same import-path adjustments as Task 16 (one extra ../ on every relative import: api'../../utils/api'; retroui/icon imports keep their non-relative paths unchanged).

Apply this diff:

  1. Add import { useParams } from 'react-router-dom';.
  2. Add the default-exported wrapper:
jsx
export default function CatalogSealedPage() {
  const { game: selectedGame } = useParams();
  const [selectedSets] = useState([]);
  return <CatalogSealedView selectedSets={selectedSets} selectedGame={selectedGame} />;
}
  1. Keep CatalogSealedView and its three helper components as local (non-exported) functions below the wrapper, unchanged otherwise.
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSealedPage.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogSealedPage.jsx client/src/pages/catalog/CatalogSealedPage.test.jsx
git commit -m "feat(client): add per-game Sealed page (relocated from CatalogTab)"

Task 18: Catalog — CatalogSetsPage

Files:

  • Create: client/src/pages/catalog/CatalogSetsPage.jsx
  • Test: client/src/pages/catalog/CatalogSetsPage.test.jsx

Interfaces:

  • Consumes: useParams().game, existing CatalogSetBrowser (selectedSets, onChange, game props, unchanged), relocated bulk-sync flow (SetSelector, SyncButton, SyncStatus, MultiSyncStatus — all unchanged components), api.get('/preferences', {params:{game}}), api.put('/preferences', {selectedSets, game}) (Task 1).

  • Produces: browse UI (CatalogSetBrowser, relocated from CatalogTab.jsx's CatalogSetsView, lines 97-118) and the bulk "select sets, sync" flow (relocated from Home.jsx's SyncTab, the view === 'sync' branch, lines 228-284) on one page, sharing one persisted selectedSets state (see Global Constraints for why this unifies the two previously-separate concepts of "selected sets").

  • [ ] Step 1: Write the failing tests

jsx
// client/src/pages/catalog/CatalogSetsPage.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn(), put: vi.fn() } }));
vi.mock('../../components/CatalogSetBrowser', () => ({ default: ({ game }) => <div>catalog-set-browser:{game}</div> }));
vi.mock('../../components/SetSelector', () => ({ default: ({ game }) => <div>set-selector:{game}</div> }));
vi.mock('../../components/SyncButton', () => ({ default: () => <button>Sync</button> }));

import api from '../../utils/api';
import CatalogSetsPage from './CatalogSetsPage';

function renderAt(game) {
  return render(
    <MemoryRouter initialEntries={[`/catalog/${game}/sets`]}>
      <Routes>
        <Route path="/catalog/:game/sets" element={<CatalogSetsPage />} />
      </Routes>
    </MemoryRouter>
  );
}

describe('CatalogSetsPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({ data: { selectedSets: ['BLB'] } });
  });

  it('renders both the bulk sync flow and the browse UI, scoped to :game', async () => {
    renderAt('mtg');
    await waitFor(() => {
      expect(screen.getByText('set-selector:mtg')).toBeInTheDocument();
    });
    expect(screen.getByText('catalog-set-browser:mtg')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Sync' })).toBeInTheDocument();
  });

  it('loads persisted selectedSets on mount, scoped to the game', async () => {
    renderAt('pokemon');
    await waitFor(() => {
      expect(api.get).toHaveBeenCalledWith('/preferences', { params: { game: 'pokemon' } });
    });
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSetsPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/catalog/CatalogSetsPage.jsx
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import CatalogSetBrowser from '../../components/CatalogSetBrowser';
import SetSelector from '../../components/SetSelector';
import SyncButton from '../../components/SyncButton';
import SyncStatus from '../../components/SyncStatus';
import MultiSyncStatus from '../../components/MultiSyncStatus';
import api from '../../utils/api';
import { Alert, Card, Text } from '@/components/retroui';

export default function CatalogSetsPage() {
  const { game } = useParams();
  const [selectedSets, setSelectedSets] = useState([]);
  const [currentJobId, setCurrentJobId] = useState(null);
  const [currentSyncJobs, setCurrentSyncJobs] = useState([]);

  useEffect(() => {
    let cancelled = false;
    api.get('/preferences', { params: { game } }).then(({ data }) => {
      if (!cancelled) setSelectedSets(data.selectedSets || []);
    }).catch((error) => console.error('Failed to load preferences:', error));
    return () => { cancelled = true; };
  }, [game]);

  const handleSetChange = useCallback(async (sets) => {
    setSelectedSets(sets);
    try {
      await api.put('/preferences', { selectedSets: sets, game });
    } catch (error) {
      console.error('Failed to save preferences:', error);
    }
  }, [game]);

  const handleSyncStart = (jobId, jobs = []) => {
    setCurrentJobId(jobId);
    setCurrentSyncJobs(jobs);
  };

  const handleSyncComplete = useCallback(() => {}, []);

  const gameLabel = game === 'pokemon' ? 'Pokemon' : game === 'mtg' ? 'MTG' : game.toUpperCase();

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      {/* Bulk sync flow */}
      <Card className="p-3 sm:p-4 md:p-5">
        <div className="space-y-3 sm:space-y-4">
          <div className="space-y-1">
            <Text as="h3" className="text-base sm:text-lg font-medium">Select Sets to Sync</Text>
            <Text as="p" className="text-sm text-muted-foreground">
              Choose which {gameLabel} sets you want available in your Shopify store.
            </Text>
          </div>
          <div className="h-0.5 bg-black" />
          <SetSelector game={game} selectedSets={selectedSets} onChange={handleSetChange} />
        </div>
      </Card>

      <Card className="p-3 sm:p-4 md:p-5">
        <div className="space-y-3 sm:space-y-4">
          <Text as="h3" className="text-base sm:text-lg font-medium">Sync Products to Shopify</Text>
          <div className="h-0.5 bg-black" />
          {selectedSets.length === 0 ? (
            <Alert status="warning">
              <Alert.Title className="flex items-center gap-2">
                <ExclamationTriangleIcon className="w-4 h-4" />
                No sets selected
              </Alert.Title>
              <Alert.Description>Select sets above to sync.</Alert.Description>
            </Alert>
          ) : (
            <Text as="p" className="text-sm sm:text-base">
              Ready to sync <strong>{selectedSets.length}</strong> set(s): {selectedSets.join(', ')}
            </Text>
          )}
          <SyncButton game={game} selectedSets={selectedSets} onSyncStart={handleSyncStart} />
        </div>
      </Card>

      {currentJobId && (
        <Card className="p-3 sm:p-4 md:p-5">
          <div className="space-y-3 sm:space-y-4">
            <Text as="h3" className="text-base sm:text-lg font-medium">Sync Progress</Text>
            <div className="h-0.5 bg-black" />
            {currentSyncJobs && currentSyncJobs.length > 1 ? (
              <MultiSyncStatus jobs={currentSyncJobs} onComplete={handleSyncComplete} />
            ) : (
              <SyncStatus jobId={currentJobId} onComplete={handleSyncComplete} />
            )}
          </div>
        </Card>
      )}

      {/* Browse UI */}
      <Card className="p-3 sm:p-4 max-w-5xl mx-auto">
        <div className="space-y-3 sm:space-y-4">
          <div className="space-y-0.5 sm:space-y-1">
            <Text as="h3" className="text-base sm:text-lg font-medium">{gameLabel} Sets</Text>
            <Text as="p" className="text-xs sm:text-sm text-muted-foreground">
              Browse all available {gameLabel} sets. Click a row to view cards, tokens, and sealed products.
            </Text>
          </div>
          <div className="h-0.5 bg-muted" />
          <CatalogSetBrowser selectedSets={selectedSets} onChange={handleSetChange} game={game} />
        </div>
      </Card>
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSetsPage.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogSetsPage.jsx client/src/pages/catalog/CatalogSetsPage.test.jsx
git commit -m "feat(client): add per-game Sets page combining bulk sync flow and browse UI"

Task 19: Catalog — CatalogGameSettingsLayout

Files:

  • Create: client/src/pages/catalog/CatalogGameSettingsLayout.jsx
  • Test: client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx

Interfaces:

  • Consumes: useParams().game, SubNavTabs (Task 3).

  • Produces: Pricing/Sync Settings/Metafields sub-nav + <Outlet/>, nested under /catalog/:game/settings/*.

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx
import { describe, it, expect } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import CatalogGameSettingsLayout from './CatalogGameSettingsLayout';

describe('CatalogGameSettingsLayout', () => {
  it('renders Pricing/Sync Settings/Metafields tabs and the outlet', () => {
    render(
      <MemoryRouter initialEntries={['/catalog/mtg/settings/pricing']}>
        <Routes>
          <Route path="/catalog/:game/settings" element={<CatalogGameSettingsLayout />}>
            <Route path="pricing" element={<div>pricing-content</div>} />
          </Route>
        </Routes>
      </MemoryRouter>
    );
    expect(screen.getByRole('button', { name: 'Pricing' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Sync Settings' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Metafields' })).toBeInTheDocument();
    expect(screen.getByText('pricing-content')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/catalog/CatalogGameSettingsLayout.jsx
import { Outlet, useParams } from 'react-router-dom';
import SubNavTabs from '../../components/SubNavTabs';
import { Text } from '@/components/retroui';

export default function CatalogGameSettingsLayout({ isEmbedded = false }) {
  const { game } = useParams();

  const tabs = [
    { label: 'Pricing', path: `/catalog/${game}/settings/pricing` },
    { label: 'Sync Settings', path: `/catalog/${game}/settings/sync` },
    { label: 'Metafields', path: `/catalog/${game}/settings/metafields` },
  ];

  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="max-w-5xl mx-auto">
        <SubNavTabs items={tabs} isEmbedded={isEmbedded} />
        <Text as="p" className="text-xs text-muted-foreground mt-2">
          These settings apply to all of your enabled catalogs, not just this one.
        </Text>
      </div>
      <Outlet />
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogGameSettingsLayout.jsx client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx
git commit -m "feat(client): add per-game catalog settings sub-nav layout"

Task 20: Catalog — CatalogSettingsPricingPage

Files:

  • Create: client/src/pages/catalog/CatalogSettingsPricingPage.jsx
  • Test: client/src/pages/catalog/CatalogSettingsPricingPage.test.jsx

Interfaces:

  • Consumes: api.get('/pricing-config'), api.put('/pricing-config', ...) — store-wide, no game param (see Global Constraints).

  • Produces: identical behavior to the "Pricing Configuration" Card from Home.jsx's SyncSettingsView (lines 672-1076).

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogSettingsPricingPage.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn(), put: vi.fn() } }));

import api from '../../utils/api';
import CatalogSettingsPricingPage from './CatalogSettingsPricingPage';

describe('CatalogSettingsPricingPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({ data: { useRawPlatformPricing: false, sourcePriority: ['tcgplayer', 'cardkingdom'] } });
  });

  it('loads the store-wide pricing config on mount', async () => {
    render(<CatalogSettingsPricingPage />);
    await waitFor(() => expect(api.get).toHaveBeenCalledWith('/pricing-config'));
    expect(await screen.findByText('Pricing Configuration')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsPricingPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement

Create the file with the exact contents cut verbatim from client/src/pages/Home.jsx:350-1079 (the full SyncSettingsView function), then split it: keep only the pricing configuration state/logic/JSX (the pricingConfig, pricingLoading, pricingSaving, pricingSaveSuccess, pricingSaveError state; loadPricingConfig, savePricingConfig, updatePricingConfig functions; and the entire "Pricing Configuration" Card JSX block, lines 672-1076) and delete the price-sync-schedule state/logic/JSX (everything schedule-related: priceSyncEnabled through saveScheduleChanges, the "Price Sync" Card block lines 541-670, and the priceFrequencyOptions/formatDate helpers if formatDate isn't used by the pricing half — check: formatDate is only used in the Price Sync card, so drop it here).

Required imports for this file (adjust relative paths by one extra ../ since it now lives in pages/catalog/): useState, useEffect from 'react'; ExclamationTriangleIcon, InfoCircledIcon from '@radix-ui/react-icons'; api from '../../utils/api'; useStore from '../../context/StoreContext'; Alert, Badge, Button, Card, Checkbox, Select, Text from '@/components/retroui' (drop Select if the pricing half doesn't use it — check: the pricing half doesn't use Select, only the schedule half does, via priceFrequencyOptions, so drop Select here).

Rename the function and make it the default export:

jsx
export default function CatalogSettingsPricingPage() {
  const { activeShop } = useStore();
  // ...(the pricing-only state and functions cut from SyncSettingsView)...

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      {/* Pricing Configuration card, cut verbatim from Home.jsx:672-1076 */}
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsPricingPage.test.jsx Expected: PASS

  • [ ] Step 5: Lint

Run: npm run lint -- client/src/pages/catalog/CatalogSettingsPricingPage.jsx Expected: no unused-import errors (confirms the schedule-only imports were correctly dropped).

  • [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogSettingsPricingPage.jsx client/src/pages/catalog/CatalogSettingsPricingPage.test.jsx
git commit -m "feat(client): add per-game route for the (store-wide) pricing config page"

Task 21: Catalog — CatalogSettingsSyncPage

Files:

  • Create: client/src/pages/catalog/CatalogSettingsSyncPage.jsx
  • Test: client/src/pages/catalog/CatalogSettingsSyncPage.test.jsx

Interfaces:

  • Consumes: api.get('/price-sync/schedule'), api.put('/price-sync/schedule', ...) — store-wide, no game param.

  • Produces: identical behavior to the "Price Sync" Card from Home.jsx's SyncSettingsView (lines 541-670).

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogSettingsSyncPage.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn(), put: vi.fn() } }));

import api from '../../utils/api';
import CatalogSettingsSyncPage from './CatalogSettingsSyncPage';

describe('CatalogSettingsSyncPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockResolvedValue({ data: { enabled: false, frequency: 'daily' } });
  });

  it('loads the store-wide price sync schedule on mount', async () => {
    render(<CatalogSettingsSyncPage />);
    await waitFor(() => expect(api.get).toHaveBeenCalledWith('/price-sync/schedule'));
    expect(await screen.findByText('Price Sync')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsSyncPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement

Create the file with the exact contents cut verbatim from client/src/pages/Home.jsx:350-670 (the schedule-only slice of SyncSettingsView): the priceSyncEnabled, priceSyncFrequency, priceScheduleLoading, priceScheduleData, customCronInput, cronValidationError, hasUnsavedScheduleChanges, scheduleSaving state; loadPriceSchedule, saveScheduleChanges functions; priceFrequencyOptions, formatDate helpers; and the "Price Sync" Card JSX block (lines 541-670).

Required imports (one extra ../): useState, useEffect from 'react'; InfoCircledIcon from '@radix-ui/react-icons' (this half doesn't use ExclamationTriangleIcon — confirm and drop it); api from '../../utils/api'; useStore from '../../context/StoreContext'; Alert, Badge, Button, Card, Checkbox, Select, Text from '@/components/retroui'.

jsx
export default function CatalogSettingsSyncPage() {
  const { activeShop } = useStore();
  // ...(the schedule-only state and functions cut from SyncSettingsView)...

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      {/* Price Sync card, cut verbatim from Home.jsx:541-670 */}
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsSyncPage.test.jsx Expected: PASS

  • [ ] Step 5: Lint

Run: npm run lint -- client/src/pages/catalog/CatalogSettingsSyncPage.jsx Expected: no unused-import errors.

  • [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogSettingsSyncPage.jsx client/src/pages/catalog/CatalogSettingsSyncPage.test.jsx
git commit -m "feat(client): add per-game route for the (store-wide) price sync schedule page"

Task 22: Catalog — CatalogSettingsMetafieldsPage

Files:

  • Create: client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx
  • Test: client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx

Interfaces:

  • Consumes: existing SetupMetafields component (onStatusChange prop, unchanged) — store-wide, no game param.

  • Produces: same content as the "Metafield Setup" Card from Home.jsx's SettingsTab (lines 1087-1090), now on its own route.

  • [ ] Step 1: Write the failing test

jsx
// client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx
import { describe, it, expect } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../../components/SetupMetafields', () => ({ default: () => <div>setup-metafields</div> }));

import CatalogSettingsMetafieldsPage from './CatalogSettingsMetafieldsPage';

describe('CatalogSettingsMetafieldsPage', () => {
  it('renders SetupMetafields', () => {
    render(<CatalogSettingsMetafieldsPage />);
    expect(screen.getByText('setup-metafields')).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
jsx
// client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx
import { useState } from 'react';
import SetupMetafields from '../../components/SetupMetafields';
import { Card } from '@/components/retroui';

export default function CatalogSettingsMetafieldsPage() {
  const [, setMetafieldStatus] = useState(null);

  return (
    <div className="max-w-5xl mx-auto space-y-4 sm:space-y-6">
      <Card className="p-3 sm:p-4 md:p-5">
        <SetupMetafields onStatusChange={setMetafieldStatus} />
      </Card>
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx Expected: PASS

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx
git commit -m "feat(client): add per-game route for the (store-wide) metafields setup page"

Task 23: App.jsx — final route wiring + delete superseded files

Files:

  • Modify: client/src/App.jsx (full rewrite)
  • Delete: client/src/pages/Home.jsx
  • Delete: client/src/components/CatalogTab.jsx
  • Delete: client/src/pages/Catalog.jsx
  • Test: client/src/App.test.jsx

Interfaces:

  • Consumes: every page/layout component from Tasks 7-22, RadixShell (Task 6), Dashboard (Task 14, now takes an isEmbedded prop).

  • Produces: the full route table described in the spec, including all legacy redirects.

  • [ ] Step 1: Write the failing routing tests

jsx
// client/src/App.test.jsx
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

// Mock every leaf page so this test only exercises routing, not page internals.
vi.mock('./pages/Dashboard', () => ({ default: () => <div>dashboard-page</div> }));
vi.mock('./pages/Queue', () => ({ default: () => <div>queue-page</div> }));
vi.mock('./pages/settings/SettingsLayout', () => ({ default: () => <div>settings-layout</div> }));
vi.mock('./pages/settings/GeneralSettings', () => ({ default: () => <div>general-settings</div> }));
vi.mock('./pages/settings/CatalogSettings', () => ({ default: () => <div>catalog-settings</div> }));
vi.mock('./pages/settings/BillingSettings', () => ({ default: () => <div>billing-settings</div> }));
vi.mock('./pages/catalog/CatalogGameLayout', () => ({ default: () => <div>catalog-game-layout</div> }));
vi.mock('./pages/catalog/CatalogGameSettingsLayout', () => ({ default: () => <div>catalog-game-settings-layout</div> }));
vi.mock('./pages/catalog/CatalogSinglesPage', () => ({ default: () => <div>catalog-singles-page</div> }));
vi.mock('./pages/catalog/CatalogSealedPage', () => ({ default: () => <div>catalog-sealed-page</div> }));
vi.mock('./pages/catalog/CatalogSetsPage', () => ({ default: () => <div>catalog-sets-page</div> }));
vi.mock('./pages/catalog/CatalogSettingsPricingPage', () => ({ default: () => <div>catalog-settings-pricing-page</div> }));
vi.mock('./pages/catalog/CatalogSettingsSyncPage', () => ({ default: () => <div>catalog-settings-sync-page</div> }));
vi.mock('./pages/catalog/CatalogSettingsMetafieldsPage', () => ({ default: () => <div>catalog-settings-metafields-page</div> }));
vi.mock('./pages/Auth', () => ({ default: () => <div>auth-page</div> }));
vi.mock('./context/StoreContext', () => ({
  StoreProvider: ({ children }) => <div>{children}</div>,
  useStore: () => ({ stores: [], activeShop: null, activeStoreLabel: '', hasActiveConnectedStore: false, setActiveShop: vi.fn(), enabledCatalogs: [], loading: false }),
}));
vi.mock('./utils/api', () => ({ isAuthenticated: () => true, setAuthData: vi.fn(), logout: vi.fn(), default: { get: vi.fn(() => Promise.resolve({ data: { games: [] } })) } }));

async function renderAppAt(path) {
  window.history.pushState({}, '', path);
  const { default: App } = await import('./App');
  return render(<App />);
}

describe('App routing — legacy redirects (embedded)', () => {
  it('/sync redirects to /catalog/mtg/sets', async () => {
    await renderAppAt('/sync?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('catalog-game-layout')).toBeInTheDocument());
  });

  it('/sync/pokemon redirects to /catalog/pokemon/sets', async () => {
    await renderAppAt('/sync/pokemon?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('catalog-game-layout')).toBeInTheDocument());
  });

  it('/settings redirects to /settings/general', async () => {
    await renderAppAt('/settings?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('settings-layout')).toBeInTheDocument());
  });

  it('/catalog redirects to /catalog/mtg/singles', async () => {
    await renderAppAt('/catalog?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('catalog-game-layout')).toBeInTheDocument());
  });

  it('/ redirects to /dashboard', async () => {
    await renderAppAt('/?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('dashboard-page')).toBeInTheDocument());
  });

  it('/queue renders the Queue page', async () => {
    await renderAppAt('/queue?shop=test.myshopify.com');
    await waitFor(() => expect(screen.getByText('queue-page')).toBeInTheDocument());
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/App.test.jsx Expected: FAIL — current App.jsx still routes /, /sync, /sync/pokemon, /settings to Home.

  • [ ] Step 3: Implement

Replace client/src/App.jsx in full. The embedded/standalone detection, useOAuthTokenHandler, ProtectedRoute, and EmbeddedCatchAll helpers (lines 1-90 of the old file) are unchanged; only the AppRouter route table (lines 92-199) and imports change:

jsx
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import Auth from './pages/Auth';
import Queue from './pages/Queue';
import SettingsLayout from './pages/settings/SettingsLayout';
import GeneralSettings from './pages/settings/GeneralSettings';
import CatalogSettings from './pages/settings/CatalogSettings';
import BillingSettings from './pages/settings/BillingSettings';
import CatalogGameLayout from './pages/catalog/CatalogGameLayout';
import CatalogGameSettingsLayout from './pages/catalog/CatalogGameSettingsLayout';
import CatalogSinglesPage from './pages/catalog/CatalogSinglesPage';
import CatalogSealedPage from './pages/catalog/CatalogSealedPage';
import CatalogSetsPage from './pages/catalog/CatalogSetsPage';
import CatalogSettingsPricingPage from './pages/catalog/CatalogSettingsPricingPage';
import CatalogSettingsSyncPage from './pages/catalog/CatalogSettingsSyncPage';
import CatalogSettingsMetafieldsPage from './pages/catalog/CatalogSettingsMetafieldsPage';
import RadixShell from './components/RadixShell';
import { StoreProvider } from './context/StoreContext';
import { ThemeProvider } from './context/ThemeContext';
import { isAuthenticated, setAuthData } from './utils/api';
import './theme.css';

// ...(useIsEmbedded, getEmbeddedShop, getEmbeddedHost, useOAuthTokenHandler, ProtectedRoute unchanged from the current file, lines 13-83)...

function EmbeddedCatchAll() {
  const shop = getEmbeddedShop();
  const target = shop ? `/dashboard?shop=${encodeURIComponent(shop)}` : '/dashboard';
  return <Navigate to={target} replace />;
}

// Route table shared by both embedded and standalone modes for everything
// past the shell — only the top-level "/" behavior and auth gating differ.
function AppRoutes({ isEmbedded }) {
  const wrap = (element) => (isEmbedded ? element : <ProtectedRoute>{element}</ProtectedRoute>);

  return (
    <Routes>
      <Route path="/dashboard" element={wrap(<Dashboard isEmbedded={isEmbedded} />)} />
      <Route path="/queue" element={wrap(<Queue />)} />

      <Route path="/settings" element={<Navigate to="/settings/general" replace />} />
      <Route path="/settings" element={wrap(<SettingsLayout isEmbedded={isEmbedded} />)}>
        <Route path="general" element={<GeneralSettings />} />
        <Route path="catalog" element={<CatalogSettings />} />
        <Route path="billing" element={<BillingSettings />} />
      </Route>

      <Route path="/catalog" element={<Navigate to="/catalog/mtg/singles" replace />} />
      <Route path="/catalog/:game" element={wrap(<CatalogGameLayout isEmbedded={isEmbedded} />)}>
        <Route index element={<Navigate to="singles" replace />} />
        <Route path="singles" element={<CatalogSinglesPage />} />
        <Route path="sealed" element={<CatalogSealedPage />} />
        <Route path="sets" element={<CatalogSetsPage />} />
        <Route path="settings" element={<CatalogGameSettingsLayout isEmbedded={isEmbedded} />}>
          <Route index element={<Navigate to="pricing" replace />} />
          <Route path="pricing" element={<CatalogSettingsPricingPage />} />
          <Route path="sync" element={<CatalogSettingsSyncPage />} />
          <Route path="metafields" element={<CatalogSettingsMetafieldsPage />} />
        </Route>
      </Route>

      {/* Legacy redirects */}
      <Route path="/sync" element={<Navigate to="/catalog/mtg/sets" replace />} />
      <Route path="/sync/pokemon" element={<Navigate to="/catalog/pokemon/sets" replace />} />

      {isEmbedded ? (
        <>
          <Route path="/" element={<Navigate to="/dashboard" replace />} />
          <Route path="*" element={<EmbeddedCatchAll />} />
        </>
      ) : (
        <>
          <Route path="/login" element={<Navigate to="/dashboard" replace />} />
          <Route path="/" element={isAuthenticated() ? <Navigate to="/dashboard" replace /> : <Navigate to="/login" replace />} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </>
      )}
    </Routes>
  );
}

function AppRouter() {
  const isEmbedded = useIsEmbedded();
  const location = useLocation();
  const navigate = useNavigate();

  useOAuthTokenHandler(isEmbedded);

  const handleAuthenticated = (_userData) => {
    navigate('/dashboard', { replace: true });
  };

  if (location.pathname === '/login' && !isEmbedded) {
    if (isAuthenticated()) {
      return <Navigate to="/dashboard" replace />;
    }
    return <Auth onAuthenticated={handleAuthenticated} />;
  }

  return (
    <StoreProvider isEmbedded={isEmbedded}>
      <RadixShell isEmbedded={isEmbedded}>
        <AppRoutes isEmbedded={isEmbedded} />
      </RadixShell>
    </StoreProvider>
  );
}

function App() {
  return (
    <ThemeProvider>
      <BrowserRouter>
        <AppRouter />
      </BrowserRouter>
    </ThemeProvider>
  );
}

export default App;
export { getEmbeddedShop, getEmbeddedHost };

Notes:

  • Catalog (the old standalone /catalog dropdown page) is no longer imported — its route is now /catalog/:game handled by CatalogGameLayout.

  • EmbeddedCatchAll's redirect target changes from / to /dashboard to match the new //dashboard embedded redirect (spec's URL map: / (embedded) → /dashboard).

  • The duplicate <Route path="/settings" .../> pair (a bare redirect plus the layout route) is intentional — react-router-dom v6 matches the first matching <Route> for an exact-path hit, so /settings itself hits the Navigate and never reaches the layout (which only has index-less child routes, so it wouldn't render anything useful for the bare path anyway). If this ordering proves fragile in testing (Step 4), replace it with a single <Route path="/settings" element={wrap(<SettingsLayout .../>)}><Route index element={<Navigate to="general" replace />} />... pattern instead, matching the /catalog/:game index-redirect approach used above — verify whichever form passes the routing tests.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- client/src/App.test.jsx Expected: PASS. If the /settings double-route causes issues, apply the index-redirect fix noted above and re-run.

  • [ ] Step 5: Delete superseded files
bash
git rm client/src/pages/Home.jsx client/src/components/CatalogTab.jsx client/src/pages/Catalog.jsx
  • [ ] Step 6: Grep for any remaining references to the deleted files

Run: grep -rn "pages/Home\|components/CatalogTab\|pages/Catalog'" client/src --include=*.jsx --include=*.js Expected: no output (confirms nothing else imports the deleted files — if anything shows up, update that import to the new equivalent page before proceeding).

  • [ ] Step 7: Run the full client test suite

Run: npm run test:client Expected: PASS across all client tests (Tasks 2-23).

  • [ ] Step 8: Commit
bash
git add client/src/App.jsx client/src/App.test.jsx
git commit -m "feat(client): wire full route table, delete Home/CatalogTab/Catalog (superseded)"

Task 24: Final verification

Files: none (verification only, no code changes expected).

  • [ ] Step 1: Run the full server test suite

Run: npm test Expected: PASS

  • [ ] Step 2: Run the full client test suite

Run: npm run test:client Expected: PASS

  • [ ] Step 3: Run lint across the whole repo

Run: npm run lint Expected: no errors. Fix any stragglers (unused imports left over from the extraction tasks are the most likely source).

  • [ ] Step 4: Build the client

Run: npm run build Expected: succeeds with no errors (catches any remaining bad import paths from the ../../ adjustments in Tasks 16-22).

  • [ ] Step 5: Manual smoke test (dev server)

Run: npm run dev, then in a browser against a connected dev store (embedded, via ngrok, per CLAUDE.md's Quick Start):

  • [ ] Sidebar shows Dashboard, Store Settings (expands to General/Catalog/Billing), Queue, one group per enabled game (MTG/Pokemon by default), Buylist (coming soon).

  • [ ] Dashboard shows Store Info (plan, last product/price sync), Announcements placeholder, Activity (click a row → modal opens).

  • [ ] /settings/catalog toggles hide/show the corresponding sidebar Catalog group live.

  • [ ] A game's Sets page shows both the bulk sync flow and the set browser; syncing a set shows progress.

  • [ ] A game's Singles/Sealed pages load and filter independently.

  • [ ] A game's Settings > Pricing/Sync Settings/Metafields pages load and save.

  • [ ] /queue shows merged product-sync and price-update jobs.

  • [ ] Legacy links (/sync, /sync/pokemon, /settings, /catalog) redirect correctly and preserve ?shop=.

  • [ ] Toggling dark mode and resizing to mobile width still work (sidebar collapse/hamburger).

  • [ ] Step 6: Report results

Summarize pass/fail for each step above before handing off for review — do not mark the plan complete if any step failed.