Appearance
Pricing Transparency 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: Show merchants market price vs. their computed price (with a step-by-step breakdown of why) in three places: the pricing settings page (live example while editing, before saving), the sync preview modal, and catalog browse rows.
Architecture: One new pure primitive, computePriceBreakdown, composes the existing exported pipeline functions (applyPricingMod, calculateConditionPrice inline, roundPriceDecimals, applyMinimumPriceRules) step-by-step instead of just returning a final number — the same functions calculatePriceWithCondition already composes, so the display can never drift from what sync actually computes. A new POST /api/pricing-preview endpoint serves both the settings-page live example (with an optional unsaved configOverride) and on-demand breakdowns elsewhere. The sync-preview and catalog surfaces get a marketPrice/yourPrice pair attached using data that's already being fetched — no new queries for those two.
Tech Stack: Node/Express (CommonJS), Zod, Vitest (server tests use ESM import), React 18 + retroui/Tailwind (client tests use ESM + @testing-library/react).
Spec: docs/superpowers/specs/2026-07-11-pricing-transparency-design.md (see its "Note on client file names" and "Naming collision to avoid" sections — the spec was corrected in-place after the per-game pricing branch merged with main's nav restructure; those corrections are load-bearing for this plan).
Global Constraints
- Server code is CommonJS (
require/module.exports); test files use ESMimport. Vitest. - The new pipeline-breakdown field is named
pricingBreakdowneverywhere (API responses, client props) — neverbreakdown.priceCacheService's existinglatestPrice.breakdownis a different thing (raw prices across finish/provider combos, fromextractAllPrices) and must not be confused with it. - Rarity keys are lowercase; finish keys are each plugin's price-bucket ids (MTG:
normal|foil|etched; Pokemon:normal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoil). Pokemon's minimum-price floor is per-rarity only (plugin.minimumPriceGranularity === 'rarity') —applyMinimumPriceRulesalready handles this;computePriceBreakdownjust delegates to it and reports whatever it did. - Every price computation resolves config via
getGamePricingConfig(store, gameId)(already shipped) — never re-derive defaults ad hoc. - New endpoints validate with Zod via
server/schemas/, following the existing per-game schema-factory pattern (buildPricingConfigSchema(plugin)inserver/schemas/pricingConfig.js) where the schema depends on which game is requested. - Client API calls go through
client/src/utils/api.js(api.get(path, {params}),api.post(path, body, {params})). - Commit after every task; pre-commit hook runs ESLint — 0 new errors (pre-existing warnings, e.g.
security/detect-object-injection, are not your concern). - Tests co-located as
filename.test.js/.test.jsx. Runnpm test(server) /npm run test:client(client) after each task; this codebase's CI machine gets resource-contention flaky failures under load (a prior session hit this repeatedly) — if a test fails only under the full suite but passes cleanly in isolation (npx vitest run <file>), it's a flake, not a regression; note it in your report rather than chasing it.
Task 1: computePriceBreakdown + raw-price timeseries helpers
Files:
- Modify:
server/services/priceLookupService.js(addcomputePriceBreakdown,findRawPriceForBucket,findRawPokemonPriceInTimeseries; add all three tomodule.exports) - Test:
server/services/priceLookupService.test.js
Interfaces:
Consumes: existing exported
applyPricingMod(priceRaw, config),roundPriceDecimals(price, config),applyMinimumPriceRules(price, finishKey, rarity, config),DEFAULT_CONDITION_MULTIPLIERS,DEFAULT_CONFIG, and the module-local (already-defined, non-exported)roundToCents(value)helper — all in this same file.Produces:
computePriceBreakdown(rawPrice, finishKey, rarity, condition = 'nm', config = DEFAULT_CONFIG)→{ marketPrice: number, yourPrice: number, pricingBreakdown: Array<{step, applied, before, after, floor?, floorSource?}> }. Steps are always['modifier', 'condition', 'rounding', 'minimum']in that order.floor/floorSource('byRarity'|'global') are only present on the'minimum'step, and only whenappliedis true.findRawPriceForBucket(priceDocuments, bucketKey, sourcePriority)→{ rawPrice: number|null, source: string|null, timestamp: Date|null }. IteratespriceDocuments(newest-first) andsourcePriorityin order, readingdoc.paper[source].retail[bucketKey]directly — no display-name mapping (unlikegetRawPriceForFinish/getRawPokemonPriceForFinish, which map MTGJSON/TCGCSV display names to bucket keys viaFINISH_MAP/POKEMON_FINISH_MAP). Works for any game since MTG/Pokemon/Riftbound price docs share the{ paper: { [source]: { retail: { [bucketKey]: number } } } }shape.findRawPokemonPriceInTimeseries(priceDocuments, finishKey, config = DEFAULT_CONFIG)→{ rawPrice: number|null, priceType: string }. Mirrors the existingfindRawPriceInTimeseries(MTG) but for Pokemon, usinggetRawPokemonPriceForFinish(already exported) instead ofgetRawPriceForFinish.finishKeyhere is a TCGCSV display finish (e.g.'Holofoil'), matchinggetRawPokemonPriceForFinish's existing contract — NOT the bucket key (that distinction matters for Task 4, which calls this withvariant.finish, a display value).
[ ] Step 1: Write the failing tests
Add to server/services/priceLookupService.test.js (the file already imports applyMinimumPriceRules, getRawPriceForFinish, etc. from ./priceLookupService.js — extend that import list with computePriceBreakdown, findRawPriceForBucket, findRawPokemonPriceInTimeseries, calculatePriceWithCondition):
js
describe('computePriceBreakdown', () => {
const config = {
globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
conditionVariants: {
enabled: true,
conditionMultipliers: { nm: 1.0, lp: 0.95, mp: 0.85, hp: 0.70, damaged: 0.50 }
},
rounding: { enabled: true, decimals: [39, 59, 79, 99] },
minimumPrices: {
enabled: true,
global: 0.59,
byRarity: { rare: { normal: 1.29, foil: 1.29 } }
}
};
it('matches calculatePriceWithCondition exactly (no-drift guarantee)', () => {
const cases = [
[4.20, 'normal', 'rare', 'nm'],
[4.20, 'foil', 'rare', 'lp'],
[0.10, 'normal', 'common', 'nm'],
[100.00, 'normal', 'rare', 'nm']
];
for (const [rawPrice, finishKey, rarity, condition] of cases) {
const { yourPrice } = computePriceBreakdown(rawPrice, finishKey, rarity, condition, config);
const expected = calculatePriceWithCondition(rawPrice, finishKey, rarity, condition, config);
expect(yourPrice).toBe(expected);
}
});
it('reports marketPrice as the rounded-to-cents raw price', () => {
const { marketPrice } = computePriceBreakdown(4.2, 'normal', 'rare', 'nm', config);
expect(marketPrice).toBe(4.20);
});
it('breakdown steps are always modifier, condition, rounding, minimum in order', () => {
const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'nm', config);
expect(pricingBreakdown.map((s) => s.step)).toEqual(['modifier', 'condition', 'rounding', 'minimum']);
});
it('marks a step not applied when it does not change the price', () => {
// NM condition never applies the condition multiplier (only non-NM does)
const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'nm', config);
const conditionStep = pricingBreakdown.find((s) => s.step === 'condition');
expect(conditionStep.applied).toBe(false);
expect(conditionStep.before).toBe(conditionStep.after);
});
it('marks the condition step applied for a non-NM condition when enabled', () => {
const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'lp', config);
const conditionStep = pricingBreakdown.find((s) => s.step === 'condition');
expect(conditionStep.applied).toBe(true);
expect(conditionStep.after).toBeLessThan(conditionStep.before);
});
it('reports floorSource byRarity when the rarity/finish floor is the binding one', () => {
const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'foil', 'rare', 'nm', config);
const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
expect(minStep.applied).toBe(true);
expect(minStep.floorSource).toBe('byRarity');
expect(minStep.floor).toBe(1.29);
expect(yourPrice).toBe(1.29);
});
it('reports floorSource global when no rarity/finish floor applies', () => {
const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'normal', 'common', 'nm', config);
const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
expect(minStep.applied).toBe(true);
expect(minStep.floorSource).toBe('global');
expect(minStep.floor).toBe(0.59);
expect(yourPrice).toBe(0.59);
});
it('handles a rarity-only (Pokemon-shaped) byRarity value the same way applyMinimumPriceRules does', () => {
const pokemonConfig = {
...config,
minimumPrices: { enabled: true, global: 0.59, byRarity: { 'rare holo': 2.00 } }
};
const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'holofoil', 'rare holo', 'nm', pokemonConfig);
const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
expect(minStep.floorSource).toBe('byRarity');
expect(minStep.floor).toBe(2.00);
expect(yourPrice).toBe(2.00);
});
it('every step is unapplied and yourPrice equals marketPrice under useRawPlatformPricing', () => {
const { marketPrice, yourPrice, pricingBreakdown } = computePriceBreakdown(
4.20, 'foil', 'rare', 'lp', { ...config, useRawPlatformPricing: true }
);
expect(yourPrice).toBe(marketPrice);
expect(pricingBreakdown.every((s) => s.applied === false)).toBe(true);
});
});
describe('findRawPriceForBucket', () => {
it('returns the first non-null, non-zero price for the bucket across sourcePriority', () => {
const docs = [
{ paper: { tcgplayer: { retail: { foil: 3.50 } } }, timestamp: new Date('2026-01-02') },
{ paper: { tcgplayer: { retail: { foil: 2.00 } } }, timestamp: new Date('2026-01-01') }
];
const result = findRawPriceForBucket(docs, 'foil', ['tcgplayer', 'cardkingdom']);
expect(result).toEqual({ rawPrice: 3.50, source: 'tcgplayer', timestamp: new Date('2026-01-02') });
});
it('falls through to the next source when the first has no value for the bucket', () => {
const docs = [
{ paper: { cardkingdom: { retail: { foil: 5.00 } } }, timestamp: new Date('2026-01-01') }
];
const result = findRawPriceForBucket(docs, 'foil', ['tcgplayer', 'cardkingdom']);
expect(result).toEqual({ rawPrice: 5.00, source: 'cardkingdom', timestamp: new Date('2026-01-01') });
});
it('returns nulls when nothing matches', () => {
const result = findRawPriceForBucket([{ paper: {} }], 'foil', ['tcgplayer']);
expect(result).toEqual({ rawPrice: null, source: null, timestamp: null });
});
it('treats a zero or missing price as absent', () => {
const docs = [{ paper: { tcgplayer: { retail: { foil: 0 } } }, timestamp: new Date() }];
expect(findRawPriceForBucket(docs, 'foil', ['tcgplayer']).rawPrice).toBeNull();
});
});
describe('findRawPokemonPriceInTimeseries', () => {
it('returns the raw price and bucket key for the first valid document', () => {
const docs = [
{ paper: { tcgplayer: { retail: { holofoil: 4.50 } } } }
];
const result = findRawPokemonPriceInTimeseries(docs, 'Holofoil');
expect(result).toEqual({ rawPrice: 4.50, priceType: 'holofoil' });
});
it('returns null rawPrice with a normal priceType fallback when nothing matches', () => {
const result = findRawPokemonPriceInTimeseries([{ paper: {} }], 'Holofoil');
expect(result).toEqual({ rawPrice: null, priceType: 'normal' });
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run server/services/priceLookupService.test.js Expected: FAIL — computePriceBreakdown, findRawPriceForBucket, findRawPokemonPriceInTimeseries are not exported/defined; also update the import line at the top of the test file to include calculatePriceWithCondition if it wasn't already imported (check first — it may already be there from a prior task).
- [ ] Step 3: Implement
In server/services/priceLookupService.js, add near calculatePriceWithCondition (which stays unchanged — this is a parallel implementation, not a replacement, so existing callers are unaffected):
js
/**
* Same pipeline as calculatePriceWithCondition, but records a step-by-step
* breakdown instead of just the final number, for display surfaces (pricing
* settings live example, sync preview, catalog rows). Never used by the
* actual sync path — calculatePriceWithCondition remains the source of
* truth there; the equivalence test above pins yourPrice to always match it.
* @param {number} rawPrice - Raw market price
* @param {string} finishKey - Price-bucket finish key
* @param {string} rarity - Card rarity (lowercase)
* @param {string} condition - Condition code, default 'nm'
* @param {object} config - Resolved per-game pricing config
* @returns {{ marketPrice: number, yourPrice: number, pricingBreakdown: Array<object> }}
*/
function computePriceBreakdown(rawPrice, finishKey, rarity, condition = 'nm', config = DEFAULT_CONFIG) {
const marketPrice = roundToCents(rawPrice);
if (config.useRawPlatformPricing) {
return {
marketPrice,
yourPrice: marketPrice,
pricingBreakdown: ['modifier', 'condition', 'rounding', 'minimum'].map((step) => ({
step, applied: false, before: marketPrice, after: marketPrice
}))
};
}
const pricingBreakdown = [];
// Step 1: modifier
let before = marketPrice;
let price = applyPricingMod(rawPrice, config);
pricingBreakdown.push({ step: 'modifier', applied: price !== before, before, after: price });
// Step 2: condition
before = price;
if (condition !== 'nm') {
const conditionConfig = config.conditionVariants || DEFAULT_CONFIG.conditionVariants;
const conditionEnabled = !!(conditionConfig && conditionConfig.enabled);
if (conditionEnabled) {
const multipliers = conditionConfig.conditionMultipliers || DEFAULT_CONDITION_MULTIPLIERS;
const multiplier = multipliers[condition] || DEFAULT_CONDITION_MULTIPLIERS[condition] || 1.0;
price = roundToCents(price * multiplier);
}
pricingBreakdown.push({ step: 'condition', applied: conditionEnabled, before, after: price });
} else {
pricingBreakdown.push({ step: 'condition', applied: false, before, after: price });
}
// Step 3: rounding
before = price;
price = roundPriceDecimals(price, config);
pricingBreakdown.push({ step: 'rounding', applied: price !== before, before, after: price });
// Step 4: minimum
before = price;
price = applyMinimumPriceRules(price, finishKey, rarity, config);
const applied = price !== before;
const minStep = { step: 'minimum', applied, before, after: price };
if (applied) {
const minPrices = config.minimumPrices || DEFAULT_CONFIG.minimumPrices;
const rarityEntry = minPrices.byRarity?.[rarity];
const rarityFloor = typeof rarityEntry === 'number' ? rarityEntry : rarityEntry?.[finishKey];
if (rarityFloor != null && rarityFloor === price) {
minStep.floor = rarityFloor;
minStep.floorSource = 'byRarity';
} else {
minStep.floor = minPrices.global;
minStep.floorSource = 'global';
}
}
pricingBreakdown.push(minStep);
return { marketPrice, yourPrice: price, pricingBreakdown };
}
/**
* Find the most recent non-null, non-zero raw price for a price-bucket key
* directly (no display-name mapping) — for callers that already have the
* bucket key, e.g. POST /api/pricing-preview, which receives it from the
* client. Every game's price documents share the same
* { paper: { [source]: { retail: { [bucketKey]: number } } } } shape.
* @param {Array<object>} priceDocuments - sorted newest-first
* @param {string} bucketKey
* @param {string[]} sourcePriority
* @returns {{ rawPrice: number|null, source: string|null, timestamp: Date|null }}
*/
function findRawPriceForBucket(priceDocuments, bucketKey, sourcePriority) {
for (const doc of priceDocuments || []) {
for (const source of sourcePriority) {
const retail = doc?.paper?.[source]?.retail;
const value = retail?.[bucketKey];
if (typeof value === 'number' && value > 0) {
return { rawPrice: value, source, timestamp: doc.timestamp || null };
}
}
}
return { rawPrice: null, source: null, timestamp: null };
}
/**
* Raw-only Pokemon timeseries lookup, mirroring findRawPriceInTimeseries
* (MTG). finishKey here is a TCGCSV display finish (e.g. 'Holofoil'),
* matching getRawPokemonPriceForFinish's existing contract.
* @param {Array<object>} priceDocuments - sorted newest-first
* @param {string} finishKey - display finish name
* @param {object} config
* @returns {{ rawPrice: number|null, priceType: string }}
*/
function findRawPokemonPriceInTimeseries(priceDocuments, finishKey, config = DEFAULT_CONFIG) {
for (const priceData of priceDocuments) {
const { rawPrice, priceType } = getRawPokemonPriceForFinish(priceData, finishKey);
if (rawPrice !== null && rawPrice > 0) {
return { rawPrice, priceType };
}
}
return { rawPrice: null, priceType: 'normal' };
}Add to module.exports (in the existing object, anywhere — e.g. near calculatePriceWithCondition):
js
computePriceBreakdown,
findRawPriceForBucket,
findRawPokemonPriceInTimeseries,- [ ] Step 4: Run to verify it passes
Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS (all new tests plus all pre-existing ones).
- [ ] Step 5: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): add computePriceBreakdown primitive and bucket-key raw-price lookups"Task 2: Zod schema for /pricing-preview
Files:
- Create:
server/schemas/pricingPreview.js - Modify:
server/schemas/index.js(add...require('./pricingPreview')to the barrel) - Test:
server/schemas/schemas.test.js
Interfaces:
Consumes:
buildPricingConfigSchema(plugin)fromserver/schemas/pricingConfig.js.Produces:
buildPricingPreviewSchema(plugin)→ Zod schema for the wholePOST /pricing-previewbody:{ cards: Array<{id, finish, rarity}>, configOverride?: <partial pricing config> }.finish/rarityon each card are restricted toplugin.finishes/plugin.rarities(same enum-of-known-ids pattern asbuildPricingConfigSchema).cardsis 1–50 items.[ ] Step 1: Write the failing test
Add to server/schemas/schemas.test.js (it already imports buildPricingConfigSchema and getPlugin — extend the import to include buildPricingPreviewSchema):
js
describe('buildPricingPreviewSchema', () => {
const mtgSchema = buildPricingPreviewSchema(getPlugin('mtg'));
const pokemonSchema = buildPricingPreviewSchema(getPlugin('pokemon'));
it('accepts a valid single-card request with no override', () => {
const r = mtgSchema.safeParse({
cards: [{ id: 'uuid-1', finish: 'foil', rarity: 'rare' }]
});
expect(r.success).toBe(true);
});
it('accepts a valid configOverride alongside cards', () => {
const r = mtgSchema.safeParse({
cards: [{ id: 'uuid-1', finish: 'normal', rarity: 'common' }],
configOverride: { globalModifier: { value: 1.25 } }
});
expect(r.success).toBe(true);
});
it('rejects an empty cards array', () => {
expect(mtgSchema.safeParse({ cards: [] }).success).toBe(false);
});
it('rejects more than 50 cards', () => {
const cards = Array.from({ length: 51 }, (_, i) => ({ id: `c${i}`, finish: 'normal', rarity: 'common' }));
expect(mtgSchema.safeParse({ cards }).success).toBe(false);
});
it('rejects a finish that does not belong to the game', () => {
const r = mtgSchema.safeParse({
cards: [{ id: 'uuid-1', finish: 'holofoil', rarity: 'rare' }]
});
expect(r.success).toBe(false);
});
it('rejects a rarity that does not belong to the game', () => {
const r = pokemonSchema.safeParse({
cards: [{ id: 'c1', finish: 'holofoil', rarity: 'mythic' }]
});
expect(r.success).toBe(false);
});
it('rejects a configOverride with a cross-game rarity key', () => {
const r = mtgSchema.safeParse({
cards: [{ id: 'uuid-1', finish: 'foil', rarity: 'rare' }],
configOverride: { minimumPrices: { byRarity: { 'rare holo': { foil: 1 } } } }
});
expect(r.success).toBe(false);
});
it('accepts a pokemon rarity-only configOverride', () => {
const r = pokemonSchema.safeParse({
cards: [{ id: 'c1', finish: 'holofoil', rarity: 'rare holo' }],
configOverride: { minimumPrices: { byRarity: { 'rare holo': 2.5 } } }
});
expect(r.success).toBe(true);
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run server/schemas/schemas.test.js Expected: FAIL — buildPricingPreviewSchema is not exported.
- [ ] Step 3: Implement
js
// server/schemas/pricingPreview.js
/**
* Zod schema for POST /api/pricing-preview — validates the batch of cards
* to price and an optional unsaved configOverride, both restricted to the
* requested game's actual rarity/finish vocabulary.
*/
'use strict';
const { z } = require('zod');
const { buildPricingConfigSchema } = require('./pricingConfig');
const MAX_PREVIEW_CARDS = 50;
function buildPricingPreviewSchema(plugin) {
const rarityKeys = Object.keys(plugin.rarities);
const finishKeys = Object.keys(plugin.finishes);
const cardSchema = z.object({
id: z.string().min(1),
finish: z.enum([...finishKeys]),
rarity: z.enum([...rarityKeys]),
});
return z.object({
cards: z.array(cardSchema).min(1, 'cards must not be empty').max(MAX_PREVIEW_CARDS, `cards must not exceed ${MAX_PREVIEW_CARDS}`),
configOverride: buildPricingConfigSchema(plugin).optional(),
});
}
module.exports = {
buildPricingPreviewSchema,
MAX_PREVIEW_CARDS,
};In server/schemas/index.js, add alongside the existing pricing entries:
js
...require('./pricingPreview'),- [ ] Step 4: Run to verify it passes
Run: npx vitest run server/schemas/schemas.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/schemas/pricingPreview.js server/schemas/index.js server/schemas/schemas.test.js
git commit -m "feat(pricing): add per-game Zod schema for the pricing-preview request body"Task 3: POST /api/pricing-preview route
Files:
- Modify:
server/routes/api.js(add route near the existingGET/PUT /pricing-configroutes, ~line 1690-1900)
Interfaces:
Consumes:
isGameSupported/getPlugin(server/plugins),getGamePricingConfig/deepMerge(server/services/pricingConfigService.js—deepMergeis already exported per that service's module.exports),buildPricingPreviewSchema(Task 2),findRawPriceForBucket/computePriceBreakdown(Task 1),plugin.preloadPrices(cardIds)(existing SYNC INTERFACE method, returnsMap<cardId, priceDocuments[]>).Produces (HTTP):
POST /api/pricing-preview?game=mtg|pokemonwith body{ cards: [{id, finish, rarity}], configOverride? }→{ results: [{ id, finish, marketPrice, source, timestamp, yourPrice, pricingBreakdown }] }. A card with no price data returnsmarketPrice: null, yourPrice: null, pricingBreakdown: []for that entry (not a request-level error). 400 on unsupported game or schema validation failure (withdetails, matching thePUT /pricing-configerror-shape convention). Nothing is persisted regardless ofconfigOverride.[ ] Step 1: Manual verification plan (no dedicated route-level test file — this repo has no supertest; route correctness is proven by (a) the Task 1/2 unit tests covering the primitives this route composes, and (b) a smoke check here)
This task has no separate TDD test file (matching the established pattern for GET/PUT /pricing-config, which also has no dedicated route test — see server/services/pricingConfigService.test.js's buildPricingConfigResponse tests instead, which test the service-layer logic the route calls). Write the route, then do a manual smoke check via curl against the local dev server (Step 4) to confirm the wiring.
- [ ] Step 2: Implement
Add to server/routes/api.js, near the existing pricing-config routes:
js
/**
* POST /api/pricing-preview
* Compute market price + your computed price (with a step-by-step
* pipeline breakdown) for a batch of cards, using either the store's saved
* pricing config or an unsaved configOverride (never persisted). Powers
* the pricing settings live example and on-demand breakdown modals.
*/
router.post('/pricing-preview', async (req, res) => {
try {
const game = req.query.game || 'mtg';
const { isGameSupported, getPlugin } = require('../plugins');
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
const plugin = getPlugin(game);
const { buildPricingPreviewSchema } = require('../schemas');
const parsed = buildPricingPreviewSchema(plugin).safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message
}))
});
}
const { cards, configOverride } = parsed.data;
const { getGamePricingConfig, deepMerge } = require('../services/pricingConfigService');
const savedConfig = getGamePricingConfig(req.store, game);
const config = configOverride ? deepMerge(savedConfig, configOverride) : savedConfig;
const { findRawPriceForBucket, computePriceBreakdown } = require('../services/priceLookupService');
const cardIds = [...new Set(cards.map((c) => c.id))];
const priceCache = await plugin.preloadPrices(cardIds);
const sourcePriority = config.sourcePriority?.length ? config.sourcePriority : [...plugin.priceSources];
const results = cards.map((card) => {
const priceDocuments = priceCache.get(card.id) || [];
const { rawPrice, source, timestamp } = findRawPriceForBucket(priceDocuments, card.finish, sourcePriority);
if (rawPrice == null) {
return {
id: card.id, finish: card.finish,
marketPrice: null, source: null, timestamp: null,
yourPrice: null, pricingBreakdown: []
};
}
const { marketPrice, yourPrice, pricingBreakdown } = computePriceBreakdown(
rawPrice, card.finish, card.rarity, 'nm', config
);
return { id: card.id, finish: card.finish, marketPrice, source, timestamp, yourPrice, pricingBreakdown };
});
res.json({ results });
} catch (error) {
logger.error('Failed to compute pricing preview', { error: error.message });
res.status(500).json({ error: 'Failed to compute pricing preview' });
}
});- [ ] Step 3: Run the full server suite to confirm nothing broke
Run: npm test Expected: all pass (this is an additive route; nothing existing should change behavior).
- [ ] Step 4: Manual smoke check
Start the dev server (npm run dev:no-worker or equivalent) and, against a store with at least one synced MTG card, run:
bash
curl -s -X POST "http://localhost:3000/api/pricing-preview?game=mtg" \
-H "Content-Type: application/json" \
-H "Cookie: <valid session cookie from a logged-in browser session>" \
-d '{"cards":[{"id":"<a real card uuid>","finish":"normal","rarity":"rare"}]}' | jqExpected: { "results": [{ "id": "...", "finish": "normal", "marketPrice": <number>, "source": "tcgplayer"|"cardkingdom", "timestamp": "...", "yourPrice": <number>, "pricingBreakdown": [...] }] }. If you don't have an easy way to get a valid session cookie, it's acceptable to verify this step by reading the code path carefully instead and noting in your report that live smoke-testing wasn't performed and why — do not skip silently.
- [ ] Step 5: Commit
bash
git add server/routes/api.js
git commit -m "feat(pricing): add POST /api/pricing-preview endpoint"Task 4: Thread marketPrice through the sync/preview pricing path
Files:
- Modify:
server/services/priceLookupService.js(getPricesForCard~line 658,getPricesForPokemonCard~line 961,updateVariantPrices~line 764,updatePokemonVariantPrices~line 1071 — locate the current line numbers yourself viagrep -n "^async function getPricesForCard\|^async function getPricesForPokemonCard\|^async function updateVariantPrices\|^async function updatePokemonVariantPrices" server/services/priceLookupService.js, they may have shifted slightly after Task 1) - Modify:
server/plugins/pokemon/index.js(applyPricing, ~line 390) - Test:
server/services/priceLookupService.test.js
Interfaces:
Consumes:
findRawPriceInTimeseries(existing, MTG),findRawPokemonPriceInTimeseries(Task 1, Pokemon).Produces:
getPricesForCard(...)'s returned object gains a_rawPrices: { [finishType]: number|null }key (sibling to the existing_isPreSalemetadata key — same underscore-prefixed convention, ignored by existing callers that don't know about it). Same forgetPricesForPokemonCard.updateVariantPrices/updatePokemonVariantPricessetvariant.marketPriceon each variant they price, alongside the existingvariant.price.PokemonPlugin.applyPricingcopiestempVariants[0].marketPricetoproduct.variantMarketPrice, alongside its existingproduct.variantpricecopy.[ ] Step 1: Write the failing tests
Add to server/services/priceLookupService.test.js:
js
describe('marketPrice threading', () => {
it('getPricesForCard exposes raw prices per finish alongside the computed prices', async () => {
const priceCache = new Map([
['card-uuid-1', [{ paper: { tcgplayer: { retail: { normal: 5.00, foil: 10.00 } } }, timestamp: new Date() }]]
]);
const prices = await getPricesForCard('card-uuid-1', 'rare', ['Normal', 'Foil'], DEFAULT_CONFIG, { priceCache });
expect(prices._rawPrices.Normal).toBe(5.00);
expect(prices._rawPrices.Foil).toBe(10.00);
});
it('updateVariantPrices sets variant.marketPrice alongside variant.price', async () => {
const priceCache = new Map([
['card-uuid-2', [{ paper: { tcgplayer: { retail: { normal: 5.00 } } }, timestamp: new Date() }]]
]);
const variants = [{ finish: 'Normal', sku: 'sku-1', price: 0 }];
await updateVariantPrices(variants, 'card-uuid-2', 'rare', DEFAULT_CONFIG, { priceCache });
expect(variants[0].marketPrice).toBe(5.00);
expect(variants[0].price).toBeGreaterThan(0);
});
it('getPricesForPokemonCard exposes raw prices per finish alongside the computed prices', async () => {
const priceCache = new Map([
['pokemon-card-1', [{ paper: { tcgplayer: { retail: { holofoil: 8.00 } } }, timestamp: new Date() }]]
]);
const prices = await getPricesForPokemonCard('pokemon-card-1', 'rare holo', ['Holofoil'], DEFAULT_CONFIG, { priceCache });
expect(prices._rawPrices.Holofoil).toBe(8.00);
});
it('updatePokemonVariantPrices sets variant.marketPrice alongside variant.price', async () => {
const priceCache = new Map([
['pokemon-card-2', [{ paper: { tcgplayer: { retail: { holofoil: 8.00 } } }, timestamp: new Date() }]]
]);
const variants = [{ finish: 'Holofoil', sku: 'sku-2', price: 0 }];
await updatePokemonVariantPrices(variants, 'pokemon-card-2', 'rare holo', DEFAULT_CONFIG, { priceCache });
expect(variants[0].marketPrice).toBe(8.00);
expect(variants[0].price).toBeGreaterThan(0);
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run server/services/priceLookupService.test.js Expected: FAIL — _rawPrices/marketPrice are undefined.
- [ ] Step 3: Implement
In getPricesForCard, inside the finishTypes.forEach loop (the one that currently only calls findValidPriceInTimeseries), also capture the raw price:
js
const prices = {};
const rawPrices = {};
const cardRarity = rarity ? rarity.toLowerCase() : '';
let hasAnyValidPrice = false;
finishTypes.forEach(finishType => {
const price = findValidPriceInTimeseries(priceDocuments, finishType, cardRarity, config);
const { rawPrice } = findRawPriceInTimeseries(priceDocuments, finishType, config);
rawPrices[finishType] = rawPrice;
if (price) {
hasAnyValidPrice = true;
prices[finishType] = price;
} else {
prices[finishType] = getPreSalePlaceholderPrice(config);
logger.debug(`No valid price found in timeseries for ${cardUUID} ${finishType}, using pre-sale placeholder: ${prices[finishType]}`);
}
});
prices._rawPrices = rawPrices;
if (!hasAnyValidPrice) {
prices._isPreSale = true;
logger.warn(`No valid prices found for any finish of card ${cardUUID} - marking as pre-sale with placeholder pricing`);
}(This adds two lines inside the existing loop — const { rawPrice } = ... and rawPrices[finishType] = rawPrice; — plus prices._rawPrices = rawPrices; before the existing if (!hasAnyValidPrice) block. The pre-sale-return branch above this loop, and the catch-block's error-return branch, both return early without price documents — leave those as-is, no _rawPrices needed since there's no raw data to report.)
In updateVariantPrices, in the variants.forEach loop:
js
variants.forEach(variant => {
const finishType = variant.finish || 'Normal';
variant.price = prices[finishType] || globalMin;
variant.marketPrice = (prices._rawPrices && prices._rawPrices[finishType]) ?? null;
logger.debug(`Updated variant price`, {
cardUUID,
finish: finishType,
price: variant.price,
isPreSale
});
});Apply the identical pattern to getPricesForPokemonCard (same finishTypes.forEach shape, using findRawPokemonPriceInTimeseries instead of findRawPriceInTimeseries) and updatePokemonVariantPrices (same variants.forEach addition). Read both functions first — they mirror getPricesForCard/updateVariantPrices closely but are not byte-identical (e.g. Pokemon's forEach may reference getPricesForPokemonCard's specific placeholder/pre-sale wording) — match the existing style of whichever function you're editing.
In server/plugins/pokemon/index.js's applyPricing (~line 390), add the market-price copy-back alongside the existing price copy-back:
js
async applyPricing(product, pricingConfig, priceCache) {
const { updatePokemonVariantPrices } = require('../../services/priceLookupService');
if (!product.sourceCardId) return { isPreSale: false };
const rarity = product.metafields?.rarity || 'common';
const tempVariants = [{
finish: product.metafields?.finish || 'normal',
sku: product.variantsku || product.sku,
price: product.variantprice || 0
}];
const { isPreSale } = await updatePokemonVariantPrices(
tempVariants, product.sourceCardId, rarity, pricingConfig, { priceCache }
);
product.variantprice = tempVariants[0].price;
product.variantMarketPrice = tempVariants[0].marketPrice;
return { isPreSale };
}(MTG needs no plugin-level change — product.variants[] already IS the real variants array there, so variant.marketPrice set inside updateVariantPrices persists directly; MTGPlugin.applyPricing just passes product.variants through by reference, unchanged.)
- [ ] Step 4: Run to verify it passes
Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS. Then run npx vitest run server/plugins/ to confirm the Pokemon plugin change didn't break its existing tests.
- [ ] Step 5: Update
/sync/preview's route doc comment to mention marketPrice is now present
In server/routes/api.js, find the GET /sync/preview route's doc comment (router.get('/sync/preview', ...), ~line 1846) and add one line noting each product/variant now carries marketPrice alongside price/variantprice (no code change needed in the route itself — previewSyncProducts()'s return value already flows straight into res.json({ success: true, preview }), so this new field appears automatically).
- [ ] Step 6: Run the full server suite
Run: npm test Expected: all pass.
- [ ] Step 7: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js server/plugins/pokemon/index.js server/routes/api.js
git commit -m "feat(pricing): thread raw market price through the sync-preview pricing path"Task 5: Catalog endpoints attach card.yourPrice
Files:
- Modify:
server/routes/api.js(all fourcard.latestPrice = priceMap.get(...)sites — locate viagrep -n "card.latestPrice = priceMap.get" server/routes/api.js, currently ~lines 293, 428, 535, 1030, covering MTG/Pokemon/Riftbound set-detail handlers and one single-card detail handler)
Interfaces:
Consumes:
getGamePricingConfig(store, gameId)(existing),computePriceBreakdown(Task 1). Each handler already hascard.rarityand, after the existingcard.latestPrice = priceMap.get(card.uuid) || nullline, alatestPriceshaped{ price, finish, provider, asOf, breakdown }(frompriceCacheService.getLatestPricesForUUIDs—finishhere is already the price-bucket key the pipeline expects, not a display name).Produces: each card object in every affected response gains
card.yourPrice: number|null—nullwhen there's nolatestPriceto compute from. No new queries;req.storeis already available on every authenticated route viadualModeAuthmiddleware.[ ] Step 1: Write the failing test
This project's route tests live at the service layer (no supertest). Since this task is a small, mechanical addition repeated at 3-4 sites in one file, verify it via a focused unit test of the shared logic instead of the route: extract nothing new (the computation is one line using already-tested computePriceBreakdown), so write a direct assertion that the pattern works against a realistic latestPrice shape, in a new small test appended to server/services/priceLookupService.test.js:
js
describe('catalog yourPrice pattern (computePriceBreakdown driving card.yourPrice)', () => {
it('computes yourPrice from a realistic latestPrice + rarity pair', () => {
const latestPrice = { price: 4.20, finish: 'foil', provider: 'tcgplayer', asOf: '2026-07-12' };
const config = {
globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
rounding: { enabled: true, decimals: [39, 59, 79, 99] },
minimumPrices: { enabled: true, global: 0.59, byRarity: {} }
};
const { yourPrice } = computePriceBreakdown(latestPrice.price, latestPrice.finish, 'rare', 'nm', config);
expect(yourPrice).toBeGreaterThan(0);
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS already (this test doesn't exercise new code — computePriceBreakdown was built in Task 1). This step is a sanity check that the shape assumption (latestPrice.finish is a bucket key) is correct, not a RED/GREEN cycle — if it fails, stop and re-read priceCacheService.pickDisplayPrice/extractAllPrices before proceeding, since that means the shape assumption this whole task rests on is wrong.
- [ ] Step 3: Implement — resolve config once per handler, attach
yourPriceper card
For each of the four sites (grep for card.latestPrice = priceMap.get), add a config resolution once near the top of that route handler (after req.store is available, before the per-card loop) and the attachment inside the existing per-card loop. Example for the MTG handler (~line 226-294 area — the other three follow the identical shape, just with that handler's own gameId):
js
// (near the top of this handler, once — not per card)
const { getGamePricingConfig } = require('../services/pricingConfigService');
const { computePriceBreakdown } = require('../services/priceLookupService');
const gamePricingConfig = getGamePricingConfig(req.store, 'mtg');js
for (const card of paginatedCards) {
card.latestPrice = priceMap.get(card.uuid) || null;
card.yourPrice = card.latestPrice
? computePriceBreakdown(
card.latestPrice.price,
card.latestPrice.finish,
(card.rarity || '').toLowerCase(),
'nm',
gamePricingConfig
).yourPrice
: null;
}Apply the same two changes (config resolution once per handler + card.yourPrice line in the loop) to the Pokemon handler (use 'pokemon' as the gameId) and the Riftbound handler ('riftbound' — Riftbound's plugin doesn't implement applyPricing/sync, but getGamePricingConfig/computePriceBreakdown don't depend on that; they only need plugin.rarities/plugin.finishes/getDefaultMinimumPrices(), which Riftbound's plugin does declare — confirm this by checking server/plugins/riftbound/index.js has no error before assuming it works, and note in your report if Riftbound's rarities/finishes/minimums aren't actually declared there yet, since that would make this a no-op returning the raw price for that game rather than a broken one).
The fourth site (~line 1030, the single-card detail endpoint) follows the same pattern — read its surrounding handler first to find which game it serves (check the route path and any game variable already in scope) and mirror the change.
- [ ] Step 4: Run the full server suite
Run: npm test Expected: all pass — this is purely additive to response payloads, no existing test should assert the absence of a yourPrice field, but double check none do (grep -n "yourPrice" server/**/*.test.js before this task should return nothing; after, only your Step 1 test).
- [ ] Step 5: Commit
bash
git add server/routes/api.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): attach computed yourPrice to catalog card responses"Task 6: Optional store auth on the catalog routes
Why this task exists: Task 5 attaches card.yourPrice on GET /catalog/cards and GET /catalog/sets, but those two routes are registered in server/routes/api.js BEFORE router.use(dualModeAuth) (~line 1527) — confirmed by grepping router.use(dualModeAuth) and both routes' registration lines, and by checking neither handler reads req.store/req.shop anywhere in its current body. req.store is undefined there today, so yourPrice can only ever reflect plugin defaults, never a merchant's actual saved pricing config — for CatalogSinglesPage.jsx (confirmed via grep -n "api.get(" client/src/pages/catalog/CatalogSinglesPage.jsx, which calls exactly these two routes), that defeats much of the point of the feature. This task closes that gap with a minimal, additive middleware change — it does not modify any existing authentication logic, only wraps it.
Client-side note (verified, not assumed): client/src/utils/api.js's request interceptor (~line 77-128) already attaches Authorization: Bearer <token> and a shop query param to every outgoing request except /user/* routes, regardless of which route is being called. No client change is needed — once these two routes read req.store, they'll start seeing it for every real embedded/standalone session the app already establishes.
Files:
- Modify:
server/middleware/dualModeAuth.js(addoptionalDualModeAuth, exported alongside the existing_setDeps/_resetDepspattern —module.exports = dualModeAuth; module.exports._setDeps = ...; module.exports._resetDeps = ...;— addmodule.exports.optionalDualModeAuth = optionalDualModeAuth;the same way) - Modify:
server/middleware/dualModeAuth.test.js - Modify:
server/routes/api.js(applyoptionalDualModeAuthto the two routes Task 5 touched)
Interfaces:
Consumes: the existing
dualModeAuth(req, res, next)function (unmodified — this task reads it, does not alter its internals or its own tests).Produces:
optionalDualModeAuth(req, res, next)— Express middleware. WhendualModeAuthwould succeed (valid session token or JWT, shop resolves to an active store), behaves identically: setsreq.shop/req.store/req.accessToken/req.authMode, callsnext(). WhendualModeAuthwould fail for ANY reason (missing shop param, invalid/expired token, shop mismatch, store not found, etc.), does NOT send an error response and does NOT setreq.store— just callsnext()with the request otherwise untouched, so the route handler proceeds exactly as it does today for an unauthenticated request.[ ] Step 1: Write the failing tests
Read server/middleware/dualModeAuth.test.js first — reuse its exact _setDeps/mock-object/beforeEach/afterEach scaffolding (do not rebuild it). Add a new top-level describe block:
js
describe('optionalDualModeAuth Middleware', () => {
// Reuses the same req/res/next/mock setup as the describe block above —
// if this file's structure nests fixtures inside a single top-level
// beforeEach, add this describe block as a sibling so it inherits the
// same req/res/next/mockTryVerify/mockStore/etc. Do not duplicate the
// fixture setup; import { optionalDualModeAuth } the same way the top
// of this file imports dualModeAuth.
it('sets req.store and calls next() when auth succeeds (same as dualModeAuth)', async () => {
req.query.shop = 'test-shop.myshopify.com';
req.headers.authorization = 'Bearer validtoken';
mockTryVerify.mockReturnValue({ shop: 'test-shop.myshopify.com', userId: 'user-1' });
const storeDoc = { shop: 'test-shop.myshopify.com', isActive: true };
mockStore.findOne.mockResolvedValue(storeDoc);
await optionalDualModeAuth(req, res, next);
expect(req.store).toBe(storeDoc);
expect(req.shop).toBe('test-shop.myshopify.com');
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('calls next() without req.store when shop parameter is missing (dualModeAuth would 400)', async () => {
await optionalDualModeAuth(req, res, next);
expect(req.store).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('calls next() without req.store when the session token is invalid and no JWT is present (dualModeAuth would 401)', async () => {
req.query.shop = 'test-shop.myshopify.com';
mockTryVerify.mockReturnValue(null);
await optionalDualModeAuth(req, res, next);
expect(req.store).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('calls next() without req.store when the session token shop does not match the requested shop (dualModeAuth would 403)', async () => {
req.query.shop = 'test-shop.myshopify.com';
req.headers.authorization = 'Bearer validtoken';
mockTryVerify.mockReturnValue({ shop: 'other-shop.myshopify.com', userId: 'user-1' });
await optionalDualModeAuth(req, res, next);
expect(req.store).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('never calls next() twice for a single request', async () => {
req.query.shop = 'test-shop.myshopify.com';
mockTryVerify.mockReturnValue(null);
await optionalDualModeAuth(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
});Update this test file's import line to also pull in optionalDualModeAuth:
js
const { default: dualModeAuth, optionalDualModeAuth, _setDeps, _resetDeps } = await import('./dualModeAuth.js');- [ ] Step 2: Run to verify it fails
Run: npx vitest run server/middleware/dualModeAuth.test.js Expected: FAIL — optionalDualModeAuth is undefined.
- [ ] Step 3: Implement
In server/middleware/dualModeAuth.js, add after the dualModeAuth function definition (do not modify dualModeAuth itself — this wraps it):
js
/**
* Same authentication logic as dualModeAuth, but never blocks the request.
* When authentication would succeed, behaves identically (req.store etc.
* are set). When it would fail for any reason (missing shop, invalid
* token, shop mismatch, store not found...), silently proceeds without
* req.store rather than sending an error response — the route handler
* must already tolerate req.store being undefined (see
* pricingConfigService.getGamePricingConfig, which degrades to plugin
* defaults). Used on routes that work for both authenticated merchants
* (real per-store data) and anonymous/public access (defaults).
*
* Implemented as a response shim around the real dualModeAuth rather than
* a parallel reimplementation, so the actual verification/token-exchange/
* ownership logic can never drift between the two.
*/
async function optionalDualModeAuth(req, res, next) {
let handled = false;
const passthroughRes = {
status: () => passthroughRes,
// dualModeAuth's final "no valid auth" branch calls res.set(...)
// (an App-Bridge-retry header) before its status().json() — this
// must be a no-op stand-in, not missing, or that branch throws
// instead of falling through.
set: () => passthroughRes,
json: () => {
if (!handled) {
handled = true;
next();
}
return passthroughRes;
}
};
await dualModeAuth(req, passthroughRes, () => {
if (!handled) {
handled = true;
next();
}
});
}Update the module's exports (currently module.exports = dualModeAuth; module.exports._setDeps = _setDeps; module.exports._resetDeps = _resetDeps;) to add:
js
module.exports.optionalDualModeAuth = optionalDualModeAuth;In server/routes/api.js, apply the new middleware to the two routes Task 5 modified (router.get('/catalog/cards', ...) and router.get('/catalog/sets', ...) or whichever function name they route to — re-locate them via grep -n "router.get('/catalog/cards'\|router.get('/catalog/sets'" server/routes/api.js). Import it near the top with the other middleware imports:
js
const { optionalDualModeAuth } = require('../middleware/dualModeAuth');Insert it as the first middleware argument on both routes, e.g.:
js
router.get('/catalog/cards', optionalDualModeAuth, validate(catalogCardsQuerySchema, { source: 'query' }), async (req, res) => {(and the equivalent for /catalog/sets). Do not reorder validate(...) relative to where it already is — optionalDualModeAuth goes before it, matching how dualModeAuth precedes route-specific middleware elsewhere in this file.
- [ ] Step 4: Run to verify it passes
Run: npx vitest run server/middleware/dualModeAuth.test.js Expected: PASS (all new tests plus every pre-existing dualModeAuth test unchanged and still green — confirms the wrapper didn't alter the wrapped function's own behavior/tests).
- [ ] Step 5: Run the full server suite
Run: npm test Expected: all pass.
- [ ] Step 6: Update the two config-resolution comments Task 5 added
In server/routes/api.js, find the two comments Task 5 added near its gamePricingConfig resolution (search for req.store is undefined on this public) and update the wording — req.store is no longer always undefined on these routes now, only when the request is genuinely unauthenticated:
js
// Resolved once for the whole request (not per card). req.store is
// populated for authenticated sessions (optionalDualModeAuth) and
// undefined for anonymous/public requests; getGamePricingConfig
// degrades to the game's plugin-declared defaults either way.- [ ] Step 7: Commit
bash
git add server/middleware/dualModeAuth.js server/middleware/dualModeAuth.test.js server/routes/api.js
git commit -m "feat(pricing): resolve the browsing merchant's real store on catalog routes"Task 7: Client — pricing settings live example
Files:
- Create:
client/src/components/PricingBreakdownList.jsx(shared presentational component — also used by Task 10's catalog breakdown modal) - Create:
client/src/components/PricingBreakdownList.test.jsx - Create:
client/src/components/PricingLiveExample.jsx - Create:
client/src/components/PricingLiveExample.test.jsx - Modify:
client/src/components/PricingConfigPanel.jsx(mount the new component, ~near the Save button at the bottom of the pricing card)
Interfaces:
Consumes:
GET /api/preferences?game=(existing, returns{ selectedSets: string[] }),GET /api/sets/:code?game=&limit=1(existing, returns{ cards: { data: [{ uuid|sourceCardId, rarity, latestPrice, ... }] } }— reuse whichever id field this game's cards actually have; MTG usesuuid, Pokemon usessourceCardId, both games' card objects already carryrarity),POST /api/pricing-preview?game=(Task 3).Produces:
<PricingBreakdownList pricingBreakdown={Array<{step, applied, before, after}>} />— presentational, renders the step list with each step dimmed whenapplied: false. No data fetching of its own; bothPricingLiveExample(this task) andPricingBreakdownModal(Task 10) render it from their own fetchedresult.pricingBreakdown.<PricingLiveExample game={game} pricingConfig={pricingConfig} />— a self-contained card that: on mount (and whenevergamechanges), fetches a default sample card (first selected set → first card in it); wheneverpricingConfig(the parent's unsaved form state) changes, debounces ~500ms then POSTs to/pricing-previewwithconfigOverride: pricingConfigfor that sample card; renders market price, your price, and<PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />.
[ ] Step 1: Write the failing test for the shared breakdown-list component
jsx
// client/src/components/PricingBreakdownList.test.jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import PricingBreakdownList from './PricingBreakdownList';
describe('PricingBreakdownList', () => {
const pricingBreakdown = [
{ step: 'modifier', applied: true, before: 4.20, after: 4.62 },
{ step: 'condition', applied: false, before: 4.62, after: 4.62 },
{ step: 'rounding', applied: true, before: 4.62, after: 4.79 },
{ step: 'minimum', applied: true, before: 4.79, after: 4.99, floor: 4.99, floorSource: 'global' }
];
it('renders one row per step with a human-readable label', () => {
render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
expect(screen.getByText('Markup')).toBeInTheDocument();
expect(screen.getByText('Condition')).toBeInTheDocument();
expect(screen.getByText('Rounding')).toBeInTheDocument();
expect(screen.getByText('Minimum')).toBeInTheDocument();
});
it('dims a step marked applied: false', () => {
render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
const conditionRow = screen.getByText('Condition').closest('[data-testid="pricing-step"]');
expect(conditionRow).toHaveAttribute('data-applied', 'false');
expect(conditionRow.className).toMatch(/opacity-40/);
});
it('does not dim an applied step', () => {
render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
const modifierRow = screen.getByText('Markup').closest('[data-testid="pricing-step"]');
expect(modifierRow).toHaveAttribute('data-applied', 'true');
expect(modifierRow.className).not.toMatch(/opacity-40/);
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownList.test.jsx Expected: FAIL — module doesn't exist.
- [ ] Step 3: Implement the shared component
jsx
// client/src/components/PricingBreakdownList.jsx
/**
* PricingBreakdownList
*
* Presentational: renders a pricingBreakdown array (from
* computePriceBreakdown, via POST /pricing-preview) as a step-by-step
* list, dimming steps that didn't apply. Shared by PricingLiveExample
* (settings page live example) and PricingBreakdownModal (catalog
* on-demand breakdown) — no data fetching of its own.
*/
import { Text } from '@/components/retroui';
import { formatPrice } from '../utils/format';
const STEP_LABELS = {
modifier: 'Markup',
condition: 'Condition',
rounding: 'Rounding',
minimum: 'Minimum'
};
export default function PricingBreakdownList({ pricingBreakdown }) {
return (
<div className="space-y-1">
{pricingBreakdown.map((step) => (
<div
key={step.step}
data-testid="pricing-step"
data-applied={String(step.applied)}
className={`flex justify-between text-sm ${step.applied ? '' : 'opacity-40'}`}
>
<Text as="span">{STEP_LABELS[step.step] || step.step}</Text>
<Text as="span">{formatPrice(step.before)} → {formatPrice(step.after)}</Text>
</div>
))}
</div>
);
}- [ ] Step 4: Run to verify it passes
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownList.test.jsx Expected: PASS
- [ ] Step 5: Commit the shared component on its own
bash
git add client/src/components/PricingBreakdownList.jsx client/src/components/PricingBreakdownList.test.jsx
git commit -m "feat(pricing): add shared PricingBreakdownList presentational component"- [ ] Step 6: Write the failing tests for PricingLiveExample
jsx
// client/src/components/PricingLiveExample.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(), post: vi.fn() } }));
import api from '../utils/api';
import PricingLiveExample from './PricingLiveExample';
describe('PricingLiveExample', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
api.get.mockImplementation((path) => {
if (path === '/preferences') return Promise.resolve({ data: { selectedSets: ['MKM'] } });
if (path === '/sets/MKM') return Promise.resolve({
data: { cards: { data: [{ uuid: 'card-1', rarity: 'rare' }] } }
});
return Promise.resolve({ data: {} });
});
api.post.mockResolvedValue({
data: {
results: [{
id: 'card-1', finish: 'normal', marketPrice: 4.20, source: 'tcgplayer',
timestamp: '2026-07-12', yourPrice: 4.99,
pricingBreakdown: [
{ step: 'modifier', applied: true, before: 4.20, after: 4.62 },
{ step: 'condition', applied: false, before: 4.62, after: 4.62 },
{ step: 'rounding', applied: true, before: 4.62, after: 4.79 },
{ step: 'minimum', applied: true, before: 4.79, after: 4.99, floor: 4.99, floorSource: 'global' }
]
}]
}
});
});
it('loads a default sample card and shows its breakdown', async () => {
render(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.1 } }} />);
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('debounces re-fetching when pricingConfig changes rapidly', async () => {
const { rerender } = render(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.1 } }} />);
await vi.advanceTimersByTimeAsync(600);
const callsAfterInitialLoad = api.post.mock.calls.length;
rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.2 } }} />);
rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.3 } }} />);
rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.4 } }} />);
await vi.advanceTimersByTimeAsync(600);
// Only one additional preview call for the whole burst of rapid edits.
expect(api.post.mock.calls.length).toBe(callsAfterInitialLoad + 1);
expect(api.post).toHaveBeenLastCalledWith(
'/pricing-preview',
expect.objectContaining({ configOverride: { globalModifier: { value: 1.4 } } }),
{ params: { game: 'mtg' } }
);
});
it('dims a step marked applied: false', async () => {
render(<PricingLiveExample game="mtg" pricingConfig={{}} />);
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => expect(screen.getByText('Condition')).toBeInTheDocument());
const conditionRow = screen.getByText('Condition').closest('[data-testid="pricing-step"]');
expect(conditionRow).toHaveAttribute('data-applied', 'false');
});
});- [ ] Step 7: Run to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingLiveExample.test.jsx Expected: FAIL — module doesn't exist.
- [ ] Step 8: Implement, using the shared
PricingBreakdownListfrom Step 3
jsx
// client/src/components/PricingLiveExample.jsx
/**
* PricingLiveExample
*
* Live worked example inside the pricing settings card: shows one sample
* card's market price -> your price, with a step-by-step breakdown, so a
* merchant can see what their (unsaved) pricing rules actually do before
* clicking Save. Recomputes against the parent's unsaved pricingConfig via
* POST /pricing-preview + configOverride (never persisted), debounced.
*/
import { useEffect, useRef, useState } from 'react';
import api from '../utils/api';
import { Card, Text } from '@/components/retroui';
import { formatPrice } from '../utils/format';
import PricingBreakdownList from './PricingBreakdownList';
const DEBOUNCE_MS = 500;
async function loadSampleCard(game) {
const { data: prefs } = await api.get('/preferences', { params: { game } });
const setCode = prefs.selectedSets?.[0];
if (!setCode) return null;
const { data } = await api.get(`/sets/${setCode}`, { params: { game, limit: 1 } });
const card = data?.cards?.data?.[0];
if (!card) return null;
return {
id: card.uuid || card.sourceCardId,
rarity: (card.rarity || 'common').toLowerCase(),
finish: 'normal'
};
}
export default function PricingLiveExample({ game, pricingConfig }) {
const [sampleCard, setSampleCard] = useState(null);
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const debounceRef = useRef(null);
// Load the sample card once per game (independent of pricingConfig edits).
useEffect(() => {
let ignore = false;
setLoading(true);
loadSampleCard(game)
.then((card) => { if (!ignore) setSampleCard(card); })
.catch((err) => { if (!ignore) setError(err.message || 'Failed to load a sample card'); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [game]);
// Debounced re-preview whenever the unsaved form state changes.
useEffect(() => {
if (!sampleCard) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const { data } = await api.post(
'/pricing-preview',
{ cards: [sampleCard], configOverride: pricingConfig },
{ params: { game } }
);
setResult(data.results[0]);
setError(null);
} catch (err) {
setError(err.response?.data?.error || 'Failed to compute preview');
}
}, DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleCard, pricingConfig, game]);
if (loading) {
return (
<Card className="p-3 sm:p-4">
<Text as="p" className="text-sm text-muted-foreground">Loading live example...</Text>
</Card>
);
}
if (!sampleCard) {
return (
<Card className="p-3 sm:p-4">
<Text as="p" className="text-sm text-muted-foreground">
Select a set to see a live pricing example.
</Text>
</Card>
);
}
return (
<Card className="p-3 sm:p-4 space-y-3">
<Text as="h4" className="text-base font-medium">Live Example</Text>
{error && <Text as="p" className="text-sm text-red-600">{error}</Text>}
{result && (
<div className="space-y-2">
<div className="flex items-center gap-4">
<div>
<Text as="p" className="text-xs text-muted-foreground">Market</Text>
<Text as="p" className="text-lg font-bold">{formatPrice(result.marketPrice)}</Text>
</div>
<Text as="span" className="text-muted-foreground">→</Text>
<div>
<Text as="p" className="text-xs text-muted-foreground">Your Price</Text>
<Text as="p" className="text-lg font-bold">{formatPrice(result.yourPrice)}</Text>
</div>
</div>
<PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />
</div>
)}
</Card>
);
}Read client/src/components/PricingConfigPanel.jsx before editing it — mount <PricingLiveExample game={game} pricingConfig={pricingConfig} /> inside the existing card, near the Save button, so it sits within the same !pricingLoading render branch (it needs pricingConfig to already be loaded). Import it at the top: import PricingLiveExample from './PricingLiveExample';.
- [ ] Step 9: Run to verify it passes
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingLiveExample.test.jsx Expected: PASS
- [ ] Step 10: Run the full client suite + build
Run: npm run test:client -- --run then npm run build Expected: PASS / clean (per the Global Constraints note on flaky full-suite runs — if something unrelated to this task flakes, confirm via isolated re-run before treating it as a regression).
- [ ] Step 11: Commit
bash
git add client/src/components/PricingLiveExample.jsx client/src/components/PricingLiveExample.test.jsx client/src/components/PricingConfigPanel.jsx
git commit -m "feat(pricing): add live pricing example to the settings panel"Task 8: Client — PreviewModal Market/Your Price columns
Files:
- Modify:
client/src/components/PreviewModal.jsx(table header ~line 293, flattening logic ~lines 65-93, price cell ~line 355) - Modify:
client/src/components/PreviewModal.test.jsx
Interfaces:
Consumes:
product.variants[].marketPrice/product.variantMarketPrice(Task 4 — already present on the API responsepreviewSyncProducts()returns, no client-side computation needed).Produces: the table's single "Price" column becomes two columns, "Market" and "Your Price"; a row whose product is pre-sale (
product._isPreSale || product._status === 'DRAFT') shows a "No market data" badge in the Market column instead of the $999.99 placeholder.[ ] Step 1: Write the failing test
Read client/src/components/PreviewModal.test.jsx first to match its existing render/mock conventions (it already renders PreviewModal with a previewData prop shaped like the real API response). Add:
jsx
it('shows separate Market and Your Price columns', () => {
const previewData = {
products: [{
_id: 'p1',
metafields: { card_name: 'Test Card', rarity: 'rare' },
variants: [{ finish: 'Normal', sku: 'sku-1', price: 4.99, marketPrice: 4.20 }]
}],
stats: { total: 1 }
};
render(<PreviewModal open previewData={previewData} syncConfig={{}} onClose={() => {}} onProceedToSync={() => {}} />);
expect(screen.getByText('$4.20')).toBeInTheDocument();
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('shows a "No market data" badge instead of the placeholder price for pre-sale rows', () => {
const previewData = {
products: [{
_id: 'p2',
_isPreSale: true,
_status: 'DRAFT',
metafields: { card_name: 'Presale Card', rarity: 'rare' },
variants: [{ finish: 'Normal', sku: 'sku-2', price: 999.99, marketPrice: null }]
}],
stats: { total: 1 }
};
render(<PreviewModal open previewData={previewData} syncConfig={{}} onClose={() => {}} onProceedToSync={() => {}} />);
expect(screen.getByText('No market data')).toBeInTheDocument();
expect(screen.queryByText('$999.99')).not.toBeInTheDocument();
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/components/PreviewModal.test.jsx Expected: FAIL — only one price column currently exists; no "No market data" badge.
- [ ] Step 3: Implement
In the flattenedProducts useMemo (~line 65-93), add variantMarketPrice: variant.marketPrice next to the existing variantprice: variant.price in the per-variant branch; the non-variant (Pokemon-shaped) branch already spreads ...product, which now includes product.variantMarketPrice from Task 4 — no change needed there.
Split the header (~line 293):
jsx
<th className="text-left px-2 sm:px-3 md:px-4 py-2 font-semibold whitespace-nowrap">Market</th>
<th className="text-left px-2 sm:px-3 md:px-4 py-2 font-semibold whitespace-nowrap">Your Price</th>Split the price cell (~line 354-356):
jsx
<td className="px-2 sm:px-3 md:px-4 py-2 whitespace-nowrap">
{(product._isPreSale || product._status === 'DRAFT')
? <Badge variant="warning">No market data</Badge>
: formatPrice(product.variantMarketPrice, '-')}
</td>
<td className="px-2 sm:px-3 md:px-4 py-2 whitespace-nowrap">
{formatPrice(product.variantprice, '-')}
</td>- [ ] Step 4: Run to verify it passes
Run: npx vitest run --config vitest.client.config.js client/src/components/PreviewModal.test.jsx Expected: PASS
- [ ] Step 5: Run the full client suite + build
Run: npm run test:client -- --run then npm run build
- [ ] Step 6: Commit
bash
git add client/src/components/PreviewModal.jsx client/src/components/PreviewModal.test.jsx
git commit -m "feat(pricing): show market vs your price columns in the sync preview modal"Task 9: Client — catalog "Your Price" column
Files:
- Modify:
client/src/pages/catalog/CatalogSinglesPage.jsx(grid view price ~line 330-337, table view header ~line 382-390, table view price ~line 420-428) - Modify:
client/src/pages/catalog/CatalogSinglesPage.test.jsx
Interfaces:
Consumes:
card.yourPrice(Task 5 — already present onGET /api/sets/:coderesponses, no client-side computation needed).Produces: both the grid and table views show a "Your Price" value next to the existing market
card.latestPricevalue. This task ships the number only; Task 10 adds the on-demand breakdown affordance on top of it.[ ] Step 1: Write the failing test
Read client/src/pages/catalog/CatalogSinglesPage.test.jsx first to match its existing render/mock conventions. Add:
jsx
it('shows a Your Price value next to the market price', async () => {
api.get.mockResolvedValue({
data: {
cards: [{ uuid: 'c1', name: 'Test Card', rarity: 'rare', latestPrice: { price: 4.20, finish: 'normal' }, yourPrice: 4.99 }],
pagination: { page: 1, pages: 1, total: 1 }
}
});
renderAt('mtg');
await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
expect(screen.getByText('$4.99')).toBeInTheDocument();
});(Use whatever the file's existing renderAt/render helper is called — do not invent a new one if one already exists.)
- [ ] Step 2: Run to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: FAIL — $4.99 not rendered anywhere yet.
- [ ] Step 3: Implement
At each of the two price-rendering sites (~line 333-336 grid view, ~line 424-427 table view), add a sibling "Your Price" value next to the existing market-price rendering. Example for the table view (mirror for the grid view using that view's existing JSX structure/classes):
jsx
<td className="px-3 py-2 border-b border-black/10">
<div
className={`font-bold ${card.latestPrice ? 'text-foreground' : 'text-muted-foreground'}`}
title={buildPriceTooltip(card.latestPrice)}
>
{card.latestPrice ? formatPrice(card.latestPrice.price, '—') : '—'}
</div>
<div className="text-xs text-muted-foreground">
Your price: {card.yourPrice != null ? formatPrice(card.yourPrice, '—') : '—'}
</div>
</td>Add the table header column split (~line 389) if the market and your-price values don't fit under one "Price" header cleanly — check the current header row first; a single "Price" header spanning both stacked values (as shown above) is fine and requires no header change, since "Your Price" renders as a sub-line under the existing cell rather than a new column. Prefer that (simpler diff) unless the existing row layout makes stacking visually cramped, in which case split into two <th>s ("Market" / "Your Price") matching Task 8's pattern.
- [ ] Step 4: Run to verify it passes
Run: npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: PASS
- [ ] Step 5: Run the full client suite + build
Run: npm run test:client -- --run then npm run build
- [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogSinglesPage.jsx client/src/pages/catalog/CatalogSinglesPage.test.jsx
git commit -m "feat(pricing): show computed Your Price alongside market price in the catalog"Task 10: Client — catalog on-demand breakdown modal
Files:
- Create:
client/src/components/PricingBreakdownModal.jsx - Create:
client/src/components/PricingBreakdownModal.test.jsx - Modify:
client/src/pages/catalog/CatalogSinglesPage.jsx(info-icon trigger next to "Your Price", modal state + mount — follow the exact existing pattern the file already uses forPriceHistoryModal: apriceModalOpen/priceModalCardstate pair, ahandleViewPrices(card)handler, and one modal instance mounted at the bottom of the component near<PriceHistoryModal ... />)
Interfaces:
Consumes:
PricingBreakdownList(Task 7),POST /api/pricing-preview?game=(Task 3 — called here with no `configOverride**, since this uses the store's saved config, not an unsaved form state).Produces:
<PricingBreakdownModal card= game={game} open={boolean} onClose={fn} />— controlled modal (sameopen/onClosecontract asPriceHistoryModal). Fetches the breakdown for that one card when it opens; shows loading/error states; renders market price, your price, and<PricingBreakdownList pricingBreakdown={...} />.[ ] Step 1: Write the failing test
jsx
// client/src/components/PricingBreakdownModal.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: { post: vi.fn() } }));
import api from '../utils/api';
import PricingBreakdownModal from './PricingBreakdownModal';
describe('PricingBreakdownModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not fetch when closed', () => {
render(
<PricingBreakdownModal
card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
game="mtg"
open={false}
onClose={() => {}}
/>
);
expect(api.post).not.toHaveBeenCalled();
});
it('fetches and renders the breakdown when opened', async () => {
api.post.mockResolvedValue({
data: {
results: [{
id: 'card-1', finish: 'normal', marketPrice: 4.20, source: 'tcgplayer',
timestamp: '2026-07-12', yourPrice: 4.99,
pricingBreakdown: [
{ step: 'modifier', applied: true, before: 4.20, after: 4.62 },
{ step: 'minimum', applied: false, before: 4.62, after: 4.62 }
]
}]
}
});
render(
<PricingBreakdownModal
card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
game="mtg"
open
onClose={() => {}}
/>
);
expect(api.post).toHaveBeenCalledWith(
'/pricing-preview',
{ cards: [{ id: 'card-1', finish: 'normal', rarity: 'rare' }] },
{ params: { game: 'mtg' } }
);
await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
expect(screen.getByText('$4.99')).toBeInTheDocument();
expect(screen.getByText('Markup')).toBeInTheDocument();
});
it('shows an error message when the request fails', async () => {
api.post.mockRejectedValue({ response: { data: { error: 'boom' } } });
render(
<PricingBreakdownModal
card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
game="mtg"
open
onClose={() => {}}
/>
);
await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument());
});
});- [ ] Step 2: Run to verify it fails
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownModal.test.jsx Expected: FAIL — module doesn't exist.
- [ ] Step 3: Implement
Read client/src/components/PriceHistoryModal.jsx first — mirror its Dialog/open/onClose/loading/error pattern exactly (same @radix-ui/react-dialog import style, same Cross2Icon close button, same overall structure) rather than inventing a new modal shape:
jsx
// client/src/components/PricingBreakdownModal.jsx
/**
* PricingBreakdownModal
*
* On-demand pricing breakdown for one catalog card, using the store's
* saved pricing config (no configOverride — unlike PricingLiveExample,
* which previews unsaved settings-page edits). Same controlled
* open/onClose contract as PriceHistoryModal.
*/
import { useEffect, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { Text } from '@/components/retroui';
import api from '../utils/api';
import { formatPrice } from '../utils/format';
import PricingBreakdownList from './PricingBreakdownList';
export default function PricingBreakdownModal({ card, game, open, onClose }) {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open || !card) return;
setLoading(true);
setError(null);
setResult(null);
api.post('/pricing-preview', { cards: [card] }, { params: { game } })
.then(({ data }) => setResult(data.results[0]))
.catch((err) => setError(err.response?.data?.error || 'Failed to load pricing breakdown'))
.finally(() => setLoading(false));
}, [open, card, game]);
return (
<Dialog.Root open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border-2 border-black p-4 max-w-sm w-full space-y-3">
<div className="flex items-center justify-between">
<Dialog.Title asChild><Text as="h3" className="text-base font-medium">Pricing Breakdown</Text></Dialog.Title>
<Dialog.Close asChild>
<button aria-label="Close" onClick={onClose}><Cross2Icon /></button>
</Dialog.Close>
</div>
{loading && <Text as="p" className="text-sm text-muted-foreground">Loading...</Text>}
{error && <Text as="p" className="text-sm text-red-600">{error}</Text>}
{result && (
<div className="space-y-2">
<div className="flex items-center gap-4">
<div>
<Text as="p" className="text-xs text-muted-foreground">Market</Text>
<Text as="p" className="text-lg font-bold">{formatPrice(result.marketPrice)}</Text>
</div>
<Text as="span" className="text-muted-foreground">→</Text>
<div>
<Text as="p" className="text-xs text-muted-foreground">Your Price</Text>
<Text as="p" className="text-lg font-bold">{formatPrice(result.yourPrice)}</Text>
</div>
</div>
<PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}In client/src/pages/catalog/CatalogSinglesPage.jsx: read the existing priceModalOpen/priceModalCard/handleViewPrices trio first (added in an earlier feature — grep for them), then add an analogous breakdownModalOpen/breakdownModalCard state pair and a handleViewBreakdown(card) handler. Add a small info-icon button next to the "Your Price" line from Task 9 (reuse InfoCircledIcon from @radix-ui/react-icons, already used elsewhere in this codebase's pricing UI):
jsx
<button
onClick={() => handleViewBreakdown(card)}
className="text-muted-foreground hover:text-foreground"
title="View pricing breakdown"
aria-label="View pricing breakdown"
>
<InfoCircledIcon className="h-3.5 w-3.5" />
</button>Mount one <PricingBreakdownModal> instance near the existing <PriceHistoryModal ... /> mount point:
jsx
<PricingBreakdownModal
card={breakdownModalCard}
game={selectedGame}
open={breakdownModalOpen}
onClose={() => {
setBreakdownModalOpen(false);
setBreakdownModalCard(null);
}}
/>breakdownModalCard should be shaped { id: card.uuid || card.sourceCardId, finish: 'normal', rarity: (card.rarity || 'common').toLowerCase() } — build this inside handleViewBreakdown, not inline in the JSX.
- [ ] Step 4: Run to verify it passes
Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownModal.test.jsx client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: PASS
- [ ] Step 5: Run the full client suite + build
Run: npm run test:client -- --run then npm run build
- [ ] Step 6: Commit
bash
git add client/src/components/PricingBreakdownModal.jsx client/src/components/PricingBreakdownModal.test.jsx client/src/pages/catalog/CatalogSinglesPage.jsx
git commit -m "feat(pricing): add on-demand pricing breakdown modal to catalog rows"Task 11: Verification sweep + docs
Files:
Modify: whatever the sweep surfaces
Modify:
CLAUDE.md/server/CLAUDE.md(pricing pipeline notes mention the new transparency primitive/endpoint)[ ] Step 1: Full server suite with coverage
Run: npm run test:coverage Expected: PASS, 70%+ on modified files.
- [ ] Step 2: Client tests + lint + build
Run: npm run test:client -- --run && npm run lint && npm run build Expected: all clean (0 new lint errors — pre-existing warnings are not in scope).
- [ ] Step 3: Grep for the naming-collision risk
Run: grep -rn "\.breakdown\b" server client --include="*.js" --include="*.jsx" | grep -v "pricingBreakdown\|extractAllPrices\|priceCacheService\|\.test\." Expected: no hits that mix up the two breakdown concepts (a few expected hits in priceCacheService.js/format.js for the pre-existing raw-price breakdown are fine).
- [ ] Step 4: Update docs
In server/CLAUDE.md's "Multi-Stage Pricing" section, add one line noting computePriceBreakdown in priceLookupService.js as the display-layer twin of calculatePriceWithCondition (same math, step-by-step), and POST /api/pricing-preview in the API Endpoints list.
- [ ] Step 5: Commit
bash
git add -A
git commit -m "chore(pricing): verification sweep + docs for pricing transparency"Post-plan reminders (not tasks)
- Deferred per the spec: overlaying "your price" on the
PriceHistoryModalchart, condition-level breakdowns in the catalog (catalog assumes NM throughout this plan), sealed-product transparency. - The pricing settings live example's sample-card selection (Task 7) only implements the "default: first card of the first selected set" path from the spec — the "search box to pick any card" enhancement is out of scope for this plan; if wanted, it's a small follow-up building on the same
PricingLiveExamplecomponent.
