Appearance
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
retrouiprimitives (@/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'sapiinstance (never rawfetch) — this fixes the existingCatalog.jsxbypass as a side effect of deleting that file. - Embedded-mode navigation must always preserve
?shop=via the sharedbuildShopAwarePathhelper (Task 2) — never callnavigate(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.jshas 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 sameSyncSettingsView-derived components against the same/pricing-configand/price-sync/scheduleendpoints. Same for Metafields (/metafield-status,/setup-metafieldsare 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. enabledCatalogsreplaces the existing deadenabledGamesStore 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.jsxlet 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.js—buildShopAwarePath(),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.jsx—enabledCatalogstoggle 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— relocatedCatalogCardsView+CardSetPicker.client/src/pages/catalog/CatalogSealedPage.jsx— relocatedCatalogSealedView+ grid/list/card sub-components.client/src/pages/catalog/CatalogSetsPage.jsx— relocatedCatalogSetsView(browse) + relocated bulk sync flow (SetSelector/SyncButton/SyncStatus/MultiSyncStatus) fromHome.jsx.client/src/pages/catalog/CatalogSettingsPricingPage.jsx— relocated pricing-pipeline half ofSyncSettingsView.client/src/pages/catalog/CatalogSettingsSyncPage.jsx— relocated price-sync-schedule half ofSyncSettingsView.client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx— relocatedSetupMetafieldsusage.
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— addenabledCatalogs+refreshPreferences(Task 5).client/src/components/VariantBudgetCard.jsx— fix double-/apiprefix bug (Task 11).server/models/Store.js— removeenabledGames, addenabledCatalogs(Task 1).server/schemas/shop.js— extendpreferencesUpdateSchema(Task 1).server/routes/api.js— extendGET/PUT /preferences(Task 1).server/schemas/schemas.test.js— addenabledCatalogscases to the existingpreferencesUpdateSchemadescribe block (Task 1).
Deleted files (Task 23):
client/src/pages/Home.jsxclient/src/components/CatalogTab.jsxclient/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(theGET/PUT /preferenceshandlers) - Modify:
server/schemas/schemas.test.js:326-341
Interfaces:
Produces:
GET /api/preferencesresponse gainsenabledCatalogs: string[].PUT /api/preferencesaccepts optionalenabledCatalogs: string[]in its body (in addition to the existing optional-nowselectedSets/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);
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- [ ] 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(),
});1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
- [ ] 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'
}
},1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
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'
}
},1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
- [ ] Step 6: Update the
GET /preferencesroute
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 });1
to:
js
res.json({ ...legacy, game, selectedSets, enabledCatalogs: req.store.enabledCatalogs });1
- [ ] Step 7: Update the
PUT /preferencesroute
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' });
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
- [ ] 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"1
2
2
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']);
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
- [ ] 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;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
- [ ] 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"1
2
2
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 />— aCard-wrapped row ofButtons, 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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- [ ] 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"1
2
2
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');
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
- [ ] 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"1
2
2
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) andrefreshPreferences: () => Promise<void>. Consumed by Task 6 (RadixShell) and Task 8 (CatalogSettingspage, 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');
});
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
- [ ] 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']);1
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);
}
}, []);1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
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]);1
2
3
4
2
3
4
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]);1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
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"1
2
2
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 alsoenabledCatalogs(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
enabledCatalogsentry, 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();
});
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
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"1
2
2
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), existingNotificationSettings,SalesChannelSettings,DeleteAllProductscomponents (no prop changes — relocated verbatim fromHome.jsx'sSettingsTab, lines 1084-1108, minusSetupMetafieldswhich 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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- [ ] 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"1
2
2
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']) });
});
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
- [ ] 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"1
2
2
Task 9: Settings — BillingSettings page
Files:
- Create:
client/src/pages/settings/BillingSettings.jsx - Test:
client/src/pages/settings/BillingSettings.test.jsx
Interfaces:
Consumes: existing
BillingStatusBannercomponent (shopprop, 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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- [ ] 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"1
2
2
Task 10: Queue page
Files:
- Create:
client/src/pages/Queue.jsx - Test:
client/src/pages/Queue.test.jsx
Interfaces:
Consumes: existing
SyncHistorycomponent (limitprop, 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/historywithout ajobTypefilter 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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- [ ] 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"1
2
2
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'}}), existingVariantBudgetCard(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'));
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- [ ] 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')1
to:
js
api.get('/variant-budget')1
- [ ] 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
StoreInfoCardtests
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());
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
- [ ] 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"1
2
2
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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
- [ ] 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"1
2
2
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, sortedcreatedAt desc, per the pre-plan audit),getJobTitle/getStatusBadgeConfig/formatDurationfrom../../utils/jobHelpers, existingSyncJobDetailModal(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');
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
- [ ] 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"1
2
2
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), existingConnectedStores,AnalyticsDashboard,userApi,useStore().Produces: fixes the
isEmbeddedinconsistency (derive fromuseStore()/props instead of a local!!searchParams.get('shop')recompute) and gatesConnectedStoresbehind!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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
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"1
2
2
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
:gameagainst/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());
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
- [ ] 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"1
2
2
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 theselectedGameprop), existingCardImage,PriceHistoryModal,CardSyncControls,useCardSyncAction/isSyncableNumberfrom../../hooks/useCardSyncAction,formatPrice/buildPriceTooltipfrom../../utils/format,getRarityOptions/getCardTypeOptions.Produces: identical behavior to the old
CatalogCardsView+CardSetPickerfromCatalogTab.jsx, nowselectedSetsis 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());
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- [ ] 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:
- Add
import { useParams } from 'react-router-dom';to the import block. - Wrap the relocated
CatalogCardsViewfunction as the page's default export, sourcingselectedGamefrom the route instead of a prop, and owning its ownselectedSetsstate (previously lifted toCatalogTab's parent):
jsx
export default function CatalogSinglesPage() {
const { game: selectedGame } = useParams();
const [selectedSets, setSelectedSets] = useState([]);
return (
<CatalogCardsView
selectedSets={selectedSets}
onSelectedSetsChange={setSelectedSets}
selectedGame={selectedGame}
/>
);
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
- Rename the original
function CatalogCardsView({ selectedSets, onSelectedSetsChange, selectedGame }) { ... }to a non-exported local function (drop anyexportkeyword 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. - Delete the trailing
export default CatalogTab;-style line if present (there isn't one on this excerpt —CatalogCardsViewhad noexport defaultin 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)"1
2
2
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+SealedProductsListViewfromCatalogTab.jsx,selectedSetsnow 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());
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- [ ] 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:
- Add
import { useParams } from 'react-router-dom';. - Add the default-exported wrapper:
jsx
export default function CatalogSealedPage() {
const { game: selectedGame } = useParams();
const [selectedSets] = useState([]);
return <CatalogSealedView selectedSets={selectedSets} selectedGame={selectedGame} />;
}1
2
3
4
5
2
3
4
5
- Keep
CatalogSealedViewand 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)"1
2
2
Task 18: Catalog — CatalogSetsPage
Files:
- Create:
client/src/pages/catalog/CatalogSetsPage.jsx - Test:
client/src/pages/catalog/CatalogSetsPage.test.jsx
Interfaces:
Consumes:
useParams().game, existingCatalogSetBrowser(selectedSets,onChange,gameprops, 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 fromCatalogTab.jsx'sCatalogSetsView, lines 97-118) and the bulk "select sets, sync" flow (relocated fromHome.jsx'sSyncTab, theview === 'sync'branch, lines 228-284) on one page, sharing one persistedselectedSetsstate (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' } });
});
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
- [ ] 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"1
2
2
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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- [ ] 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"1
2
2
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, nogameparam (see Global Constraints).Produces: identical behavior to the "Pricing Configuration"
CardfromHome.jsx'sSyncSettingsView(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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
- [ ] 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"1
2
2
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, nogameparam.Produces: identical behavior to the "Price Sync"
CardfromHome.jsx'sSyncSettingsView(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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
- [ ] 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"1
2
2
Task 22: Catalog — CatalogSettingsMetafieldsPage
Files:
- Create:
client/src/pages/catalog/CatalogSettingsMetafieldsPage.jsx - Test:
client/src/pages/catalog/CatalogSettingsMetafieldsPage.test.jsx
Interfaces:
Consumes: existing
SetupMetafieldscomponent (onStatusChangeprop, unchanged) — store-wide, nogameparam.Produces: same content as the "Metafield Setup"
CardfromHome.jsx'sSettingsTab(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();
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- [ ] 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>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- [ ] 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"1
2
2
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 anisEmbeddedprop).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());
});
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
- [ ] 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 };1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
Notes:
Catalog(the old standalone/catalogdropdown page) is no longer imported — its route is now/catalog/:gamehandled byCatalogGameLayout.EmbeddedCatchAll's redirect target changes from/to/dashboardto match the new/→/dashboardembedded 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/settingsitself hits theNavigateand 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/:gameindex-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.jsx1
- [ ] 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)"1
2
2
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/catalogtoggles 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.
[ ]
/queueshows 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.
