Appearance
Sealed Products Quick-Add 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 sealed products PreviewβImport(draft)βReview&ManageβApprove pipeline with a single + quick-add popover (quantity + price, mirroring the singles catalog's inventory control), and add a "By Release" browse mode for working through one shipment/release at a time.
Architecture: Two new server endpoints (quick-add, quick-add-bulk) that create-and-publish or top-up a SealedProduct in one call, reusing the exact Shopify plumbing (createProduct, updateVariantGraphQL, addInventoryQuantity, getDefaultLocationId) the existing import/approve routes already use. One new small Shopify helper (getVariantInventoryItemId) fills the one gap in that plumbing. Client-side: a new SealedQuickAddControls popover (modeled on CardSyncControls) replaces checkbox-select + preview-modal + Review&Manage in CatalogSealedPage.jsx; a new "By Release" view reuses two existing endpoints (GET /api/sets for the release picker, GET /catalog/sealed?sets=X for that release's products).
Tech Stack: Express + Mongoose + Zod (server), React + Vitest (client), Shopify Admin GraphQL via shopifyAPI.js.
Global Constraints β
- No
SealedProductschema changes β quantity is never persisted (Shopify inventory is the source of truth);currentPrice/pricingSourcealready exist. - The existing
draftstatus andapprove/reject/bulk-approveroutes/schemas stay working, untouched, as a safety net β just unlinked from the client. - Riftbound's catalog-only sealed browsing is unaffected (
importSupportedstaysselectedGame === 'mtg'only). - All Shopify calls go through
server/services/shopifyAPI.js(CLAUDE.md rule 6). gameis never defaulted anywhere in this diff (CLAUDE.md rule 5.5) β not applicable here since sealed quick-add is MTG-only and never threads agameparam, but no|| 'mtg'-style fallback should appear anywhere in the diff regardless.- This codebase has no existing route-level tests for
server/routes/api.js(confirmed: onlyschemas/,services/,models/,utils/,middleware/,queues/processors/have test files β not a singleroutes/api*.test.jsexists, not even for the sealed-products routes being modified here). Match that convention: new Zod schemas get tests; new route handlers are verified manually via the dev server (Task 12), not via new route-test infrastructure invented for this PR. CardSyncControls.jsx(the componentSealedQuickAddControlsis modeled on) has no dedicated test file either β only its underlying hook (useCardSyncAction.js) does. Match that:SealedQuickAddControls.jsxgets manual verification,useSealedQuickAdd.js's pure helpers get unit tests.
File Structure β
Server β new/modified:
server/schemas/sealedProducts.jsβ addquickAddSchema,quickAddBulkSchemaserver/schemas/schemas.test.jsβ tests for the two new schemasserver/services/shopifyAPI.jsβ addgetVariantInventoryItemId(variantId)server/services/shopifyAPI.test.jsβ test for the new methodserver/routes/api.jsβ addPOST /sealed-products/quick-add,POST /sealed-products/quick-add-bulk; extendGET /catalog/sealed's MTG branch withsuggestedPrice/pricingSource
Client β new:
client/src/hooks/useSealedQuickAdd.jsβ per-row draft state (quantity,price) + calls to the new endpointsclient/src/hooks/useSealedQuickAdd.test.jsclient/src/components/SealedQuickAddControls.jsxβ the+popover
Client β modified:
client/src/utils/api.jsβ addquickAdd/quickAddBulk, removeapprove/reject/bulkApprove(nothing will call them after this change)client/src/pages/catalog/CatalogSealedPage.jsxβ removeSealedReviewManage+ preview-modal import flow; wireSealedQuickAddControlsintoSealedBrowse; add newSealedByReleaseviewclient/src/pages/catalog/CatalogSealedPage.test.jsxβ update for the new tabs/controls
Task 1: Server β quick-add Zod schemas β
Files:
- Modify:
server/schemas/sealedProducts.js - Modify:
server/schemas/schemas.test.js
Interfaces:
Produces:
quickAddSchema(validates{ uuid, quantity, price }),quickAddBulkSchema(validates{ items: [...] }where each item matchesquickAddSchema). Both exported fromserver/schemas/sealedProducts.jsand re-exported throughserver/schemas/index.js(already spreadsrequire('./sealedProducts')β no barrel change needed).[ ] Step 1: Write the failing schema tests
Add to server/schemas/schemas.test.js, right after the existing describe('sealedProductsListQuerySchema', ...) block (ends around line 745):
javascript
import { quickAddSchema, quickAddBulkSchema } from './sealedProducts.js';
describe('quickAddSchema', () => {
it('accepts a valid uuid/quantity/price payload', () => {
const r = quickAddSchema.safeParse({
uuid: '550e8400-e29b-41d4-a716-446655440000',
quantity: 4,
price: 39.99,
});
expect(r.success).toBe(true);
});
it('rejects a non-uuid uuid', () => {
expect(quickAddSchema.safeParse({ uuid: 'not-a-uuid', quantity: 1, price: 10 }).success).toBe(false);
});
it('rejects a negative quantity', () => {
expect(quickAddSchema.safeParse({ uuid: '550e8400-e29b-41d4-a716-446655440000', quantity: -1, price: 10 }).success).toBe(false);
});
it('accepts quantity: 0 (publish/update price with no stock change)', () => {
const r = quickAddSchema.safeParse({ uuid: '550e8400-e29b-41d4-a716-446655440000', quantity: 0, price: 10 });
expect(r.success).toBe(true);
});
it('rejects a negative price', () => {
expect(quickAddSchema.safeParse({ uuid: '550e8400-e29b-41d4-a716-446655440000', quantity: 1, price: -5 }).success).toBe(false);
});
it('rejects a missing price', () => {
expect(quickAddSchema.safeParse({ uuid: '550e8400-e29b-41d4-a716-446655440000', quantity: 1 }).success).toBe(false);
});
});
describe('quickAddBulkSchema', () => {
it('accepts an array of valid items', () => {
const r = quickAddBulkSchema.safeParse({
items: [
{ uuid: '550e8400-e29b-41d4-a716-446655440000', quantity: 2, price: 20 },
{ uuid: '650e8400-e29b-41d4-a716-446655440001', quantity: 0, price: 15 },
],
});
expect(r.success).toBe(true);
});
it('rejects an empty items array', () => {
expect(quickAddBulkSchema.safeParse({ items: [] }).success).toBe(false);
});
it('rejects when any item is invalid', () => {
const r = quickAddBulkSchema.safeParse({
items: [{ uuid: 'not-a-uuid', quantity: 1, price: 10 }],
});
expect(r.success).toBe(false);
});
});- [ ] Step 2: Run tests to verify they fail
Run: npm test -- schemas.test.js -t "quickAdd" Expected: FAIL β quickAddSchema is not exported from ./sealedProducts.js
- [ ] Step 3: Implement the schemas
In server/schemas/sealedProducts.js, add after sealedBulkApproveSchema (before module.exports):
javascript
/**
* POST /api/sealed-products/quick-add body
*/
const quickAddSchema = z.object({
uuid: z.string().uuid(),
quantity: z.number().int().min(0).max(100000),
price: z.number().min(0).max(100000),
});
/**
* POST /api/sealed-products/quick-add-bulk body
*/
const quickAddBulkSchema = z.object({
items: z.array(quickAddSchema).min(1).max(200),
});Update module.exports:
javascript
module.exports = {
sealedImportSchema,
sealedListQuerySchema,
sealedApproveSchema,
sealedBulkApproveSchema,
quickAddSchema,
quickAddBulkSchema,
VALID_SEALED_STATUSES,
};- [ ] Step 4: Run tests to verify they pass
Run: npm test -- schemas.test.js -t "quickAdd" Expected: PASS (9 tests)
- [ ] Step 5: Commit
bash
git add server/schemas/sealedProducts.js server/schemas/schemas.test.js
git commit -m "Add Zod schemas for sealed product quick-add endpoints"Task 2: Server β shopifyAPI.getVariantInventoryItemId β
Files:
- Modify:
server/services/shopifyAPI.js - Modify:
server/services/shopifyAPI.test.js
Interfaces:
Consumes:
this.graphQL(query, variables)(existing instance method)Produces:
async getVariantInventoryItemId(variantId: string): Promise<string|null>β used by Task 3/4's route handlers to get the inventory item id needed foraddInventoryQuantity, since neithercreateProduct()norupdateVariantGraphQL()currently return one (verified:updateVariantsBulkGraphQL's selection set only requests{ id, sku, price }).[ ] Step 1: Write the failing test
Add to server/services/shopifyAPI.test.js, right after the describe('ShopifyAPI getDefaultLocationId', ...) block (ends around line 562, just before describe('ShopifyAPI adjustInventoryQuantity', ...)):
javascript
describe('ShopifyAPI getVariantInventoryItemId', () => {
let api;
let graphQLSpy;
beforeEach(() => {
api = new ShopifyAPI('test-shop.myshopify.com', 'token');
graphQLSpy = vi.fn();
api.graphQL = graphQLSpy;
});
it('returns the inventory item id for a variant', async () => {
graphQLSpy.mockResolvedValue({
productVariant: { inventoryItem: { id: 'gid://shopify/InventoryItem/123' } }
});
const id = await api.getVariantInventoryItemId('gid://shopify/ProductVariant/456');
expect(id).toBe('gid://shopify/InventoryItem/123');
expect(graphQLSpy).toHaveBeenCalledWith(
expect.stringContaining('productVariant'),
{ id: 'gid://shopify/ProductVariant/456' }
);
});
it('returns null when the variant has no inventory item', async () => {
graphQLSpy.mockResolvedValue({ productVariant: null });
expect(await api.getVariantInventoryItemId('gid://shopify/ProductVariant/456')).toBeNull();
});
});- [ ] Step 2: Run test to verify it fails
Run: npm test -- shopifyAPI.test.js -t "getVariantInventoryItemId" Expected: FAIL β api.getVariantInventoryItemId is not a function
- [ ] Step 3: Implement the method
In server/services/shopifyAPI.js, add immediately before async addInventoryQuantity(inventoryItemId, locationId, quantity) { (around line 1028):
javascript
/**
* Look up a variant's inventory item id. Needed because neither
* createProduct() nor updateVariantGraphQL()'s bulk-update mutation
* return one (their selection sets only request id/sku/price) β this
* is the one extra round trip quick-add needs before it can call
* addInventoryQuantity().
* @param {string} variantId - Full GID format
* @returns {Promise<string|null>}
*/
async getVariantInventoryItemId(variantId) {
const query = `
query getVariantInventoryItem($id: ID!) {
productVariant(id: $id) {
inventoryItem { id }
}
}
`;
const data = await this.graphQL(query, { id: variantId });
return data.productVariant?.inventoryItem?.id || null;
}- [ ] Step 4: Run test to verify it passes
Run: npm test -- shopifyAPI.test.js -t "getVariantInventoryItemId" Expected: PASS (2 tests)
- [ ] Step 5: Commit
bash
git add server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Add shopifyAPI helper to look up a variant's inventory item id"Task 3: Server β POST /api/sealed-products/quick-add β
Files:
- Modify:
server/routes/api.js
Interfaces:
- Consumes:
quickAddSchema(Task 1),shopifyAPI.getVariantInventoryItemId(Task 2),shopifyAPI.getDefaultLocationId,shopifyAPI.addInventoryQuantity,shopifyAPI.createProduct,shopifyAPI.updateVariantGraphQL,shopifyAPI.resolveConfiguredPublicationIds,categorizeProduct(fromutils/sealedProductUtils.js, already imported),SealedProductmodel (already imported),SetModel(already imported),CATEGORY_LABELS_SERVER(already defined in this file),MANAGED_BY_VALUE(already imported). - Produces: route
POST /sealed-products/quick-addβ response shape{ created: boolean, product: <SealedProduct doc>, priceUpdated: boolean, quantityAdded: number }on success,{ error: string }with 4xx/5xx on failure. Consumed by Task 6'ssealedProductsApi.quickAdd.
No test file for this task β see Global Constraints (no route-test precedent exists in this codebase); verified manually in Task 12.
- [ ] Step 1: Add the
quickAddSchema/quickAddBulkSchemaimport
In server/routes/api.js, in the destructured import from ../schemas (starts around line 26), add after sealedApproveSchema,:
javascript
quickAddSchema,
quickAddBulkSchema,- [ ] Step 2: Add the route
In server/routes/api.js, insert immediately after the closing }); of the existing router.post('/sealed-products/import', ...) handler (around line 3506, right before the /**\n * GET /api/sealed-products/preview comment block):
javascript
/**
* POST /api/sealed-products/quick-add
* Create-and-publish (or top up) a single sealed product in one step: sets
* the merchant-chosen price and adds the merchant-chosen quantity to
* inventory. Replaces the old import(draft)->approve two-step flow β
* products go live immediately, no review stage. See
* docs/superpowers/specs/2026-07-13-sealed-quick-add-design.md.
*
* Body: { uuid, quantity, price }
*/
router.post('/sealed-products/quick-add', validate(quickAddSchema), async (req, res) => {
try {
const { uuid, quantity, price } = req.body;
const shop = req.shop;
const store = req.store;
const ShopifyAPI = require('../services/shopifyAPI');
const shopifyAPI = new ShopifyAPI(shop, req.accessToken);
const existing = await SealedProduct.findOne({ shop, uuid });
if (existing) {
if (!existing.shopifyProductId || !existing.shopifyVariantId) {
return res.status(400).json({
error: 'Sealed product exists but was never successfully synced to Shopify. Re-import it from Browse & Import.'
});
}
let priceUpdated = false;
if (price !== existing.currentPrice) {
await shopifyAPI.updateVariantGraphQL(
existing.shopifyVariantId,
{ price: String(price) },
existing.shopifyProductId
);
existing.currentPrice = price;
existing.pricingSource = 'manual';
priceUpdated = true;
}
let quantityAdded = 0;
if (quantity > 0) {
const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(existing.shopifyVariantId);
const locationId = await shopifyAPI.getDefaultLocationId();
if (!locationId || !inventoryItemId) {
return res.status(500).json({ error: 'No active Shopify location found; cannot add inventory' });
}
await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
quantityAdded = quantity;
}
await existing.save();
logger.info('Sealed product quick-add: topped up existing product', {
shop, uuid, priceUpdated, quantityAdded
});
return res.json({ created: false, product: existing, priceUpdated, quantityAdded });
}
// Not yet imported β look up the one set document containing this
// uuid (narrower than sealed-products/import's bulk scan, since
// we only need a single product here).
const setDoc = await SetModel.findOne(
{ 'data.sealedProduct.uuid': uuid },
'data.code data.name data.releaseDate data.sealedProduct'
).lean();
if (!setDoc) {
return res.status(404).json({ error: 'Sealed product not found in catalog' });
}
const setData = setDoc.data;
const product = (setData.sealedProduct || []).find(p => p.uuid === uuid);
if (!product) {
return res.status(404).json({ error: 'Sealed product not found in catalog' });
}
const name = product.name || 'Unknown';
const category = categorizeProduct(name);
const tcgplayerProductId = product.identifiers?.tcgplayerProductId;
const imageUrl = tcgplayerProductId
? `https://product-images.tcgplayer.com/fit-in/400x400/${tcgplayerProductId}.jpg`
: null;
const slugifiedName = name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const sku = `sealed-${setData.code.toLowerCase()}-${slugifiedName}`;
const descriptionParts = [];
descriptionParts.push(`<h3>${name}</h3>`);
descriptionParts.push(`<p><strong>Set:</strong> ${setData.name} (${setData.code})</p>`);
descriptionParts.push(`<p><strong>Product Type:</strong> ${CATEGORY_LABELS_SERVER[category] || category}</p>`);
if (product.subtype) {
descriptionParts.push(`<p><strong>Subtype:</strong> ${product.subtype}</p>`);
}
if (setData.releaseDate) {
const releaseDate = new Date(setData.releaseDate);
descriptionParts.push(`<p><strong>Release Date:</strong> ${releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>`);
}
const publicationIds = await shopifyAPI.resolveConfiguredPublicationIds(store?.salesChannelConfig);
const mongoProduct = {
title: name,
handle: sku,
body: descriptionParts.join('\n'),
vendor: 'Wizards of the Coast',
type: 'Sealed Product',
tags: `sealed, ${setData.code}, ${category}`,
published: true, // live immediately β no draft/review stage
variantprice: price,
variantsku: sku,
variantinventorypolicy: 'deny',
imagesrc: imageUrl,
imagealttext: name,
metafields: {
set_code: setData.code,
category,
release_date: setData.releaseDate || '',
uuid,
managed_by: MANAGED_BY_VALUE
}
};
let shopifyProduct;
try {
shopifyProduct = await shopifyAPI.createProduct(mongoProduct, publicationIds);
} catch (shopifyErr) {
logger.error('Sealed product quick-add: failed to create Shopify product', {
shop, uuid, name, error: shopifyErr.message
});
return res.status(500).json({ error: 'Failed to create product in Shopify. Nothing was saved β try again.' });
}
const shopifyProductId = shopifyProduct.id;
const shopifyVariantId = shopifyProduct.variants?.edges?.[0]?.node?.id || null;
let quantityAdded = 0;
if (quantity > 0 && shopifyVariantId) {
const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(shopifyVariantId);
const locationId = await shopifyAPI.getDefaultLocationId();
if (locationId && inventoryItemId) {
await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
quantityAdded = quantity;
}
}
const sealedProduct = await SealedProduct.create({
shop,
uuid,
name,
category,
setCode: setData.code,
setName: setData.name,
releaseDate: setData.releaseDate ? new Date(setData.releaseDate) : null,
identifiers: {
tcgplayerProductId: tcgplayerProductId ? parseInt(tcgplayerProductId) : undefined,
cardmarketProductId: product.identifiers?.cardMarketId ? parseInt(product.identifiers.cardMarketId) : undefined,
mcmId: product.identifiers?.mcmId ? parseInt(product.identifiers.mcmId) : undefined
},
purchaseUrls: product.purchaseUrls || {},
currentPrice: price,
pricingSource: 'manual',
lastPriceUpdate: new Date(),
status: 'active',
requiresReview: false,
reviewedBy: shop,
reviewedAt: new Date(),
shopifyProductId,
shopifyVariantId,
syncStatus: 'synced',
lastSyncedAt: new Date()
});
logger.info('Sealed product quick-add: created and published', {
shop, uuid, name, price, quantityAdded
});
res.json({ created: true, product: sealedProduct, priceUpdated: false, quantityAdded });
} catch (error) {
logger.error('Sealed product quick-add failed', { error: error.message, shop: req.shop });
res.status(500).json({ error: 'Failed to add sealed product' });
}
});- [ ] Step 3: Sanity-check the route parses
Run: node -e "require('./server/routes/api.js')" Expected: no output (no syntax/require errors). Full behavioral verification happens in Task 12 against a real dev store, since this route talks to Shopify and MongoDB.
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Add POST /api/sealed-products/quick-add endpoint"Task 4: Server β POST /api/sealed-products/quick-add-bulk β
Files:
- Modify:
server/routes/api.js
Interfaces:
Consumes: everything Task 3 consumes, plus
quickAddBulkSchema(Task 1).Produces: route
POST /sealed-products/quick-add-bulkβ response shape{ added: number, updated: number, failed: number, results: [{ uuid, ok: boolean, created?: boolean, priceUpdated?: boolean, quantityAdded?: number, quantityWarning?: string|null, error?: string }] }. Consumed by Task 6'ssealedProductsApi.quickAddBulkand the "By Release" bulk sync button (Task 10).quantityWarning(added in this task β see Step 1) is non-null when the Shopify product was created/priced successfully but the follow-up inventory-quantity call failed; the record is never lost or duplicated in that case (see Step 1's fix for the orphan/drift bug Task 3's review found).[ ] Step 1: Extract the shared per-item logic
Task 3's handler is entirely reusable per-item logic; wrap it in a helper so both routes call the same code (DRY β avoids the bulk route drifting from the single-item route over time). Replace the router.post('/sealed-products/quick-add', ...) handler body from Task 3 with a call to a new local function, defined just above it.
This extraction also fixes a bug Task 3's review found in the original code (confirmed present verbatim in what Task 3 shipped, since that code was transcribed faithfully from this plan): in both branches, the Shopify write that must not be lost (the price update on top-up; the SealedProduct.create on first import) was ordered AFTER the inventory-quantity call, with no try/catch around that call. If getVariantInventoryItemId/getDefaultLocationId/addInventoryQuantity threw (both can β confirmed against shopifyAPI.js), the request failed with a generic 500 and the preceding Shopify write was silently lost β worse, on first-import, a retry would then create a second, duplicate live Shopify product for the same catalog uuid, since SealedProduct.findOne still found nothing. The version below fixes this: each branch persists (existing.save() / SealedProduct.create(...)) immediately once its Shopify write succeeds, and the inventory-quantity call is wrapped in its own try/catch that degrades to a quantityWarning string in the response instead of throwing β the record is never lost or duplicated, worst case the merchant is told inventory needs a retry.
javascript
/**
* Shared implementation for quick-add (single) and quick-add-bulk (per item).
* Returns { created, product, priceUpdated, quantityAdded } on success;
* throws an Error with a user-facing .message on failure, and a
* .status (HTTP code) property when the failure isn't a generic 500 β
* both routes read a QuickAddError's status to build the caller's response.
*/
class QuickAddError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
}
}
async function quickAddSealedProduct({ shop, store, accessToken, uuid, quantity, price }) {
const ShopifyAPI = require('../services/shopifyAPI');
const shopifyAPI = new ShopifyAPI(shop, accessToken);
const existing = await SealedProduct.findOne({ shop, uuid });
if (existing) {
if (!existing.shopifyProductId || !existing.shopifyVariantId) {
throw new QuickAddError(
'Sealed product exists but was never successfully synced to Shopify. Re-import it from Browse & Import.',
400
);
}
let priceUpdated = false;
if (price !== existing.currentPrice) {
await shopifyAPI.updateVariantGraphQL(
existing.shopifyVariantId,
{ price: String(price) },
existing.shopifyProductId
);
existing.currentPrice = price;
existing.pricingSource = 'manual';
priceUpdated = true;
}
// Save the price change (if any) BEFORE attempting the inventory
// add β a failure below must never discard a Shopify price update
// that already succeeded. (Fixes an orphan/drift bug found in Task
// 3's review: the original code saved only after the inventory
// step, so a throw there lost the price update entirely.)
if (priceUpdated) {
await existing.save();
}
let quantityAdded = 0;
let quantityWarning = null;
if (quantity > 0) {
try {
const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(existing.shopifyVariantId);
const locationId = await shopifyAPI.getDefaultLocationId();
if (!locationId || !inventoryItemId) {
throw new Error('No active Shopify location found');
}
await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
quantityAdded = quantity;
} catch (invErr) {
logger.warn('Sealed product quick-add: price updated but inventory add failed', {
shop, uuid, error: invErr.message
});
quantityWarning = 'Price was updated, but adding inventory failed β add it manually or retry.';
}
}
return { created: false, product: existing, priceUpdated, quantityAdded, quantityWarning };
}
const setDoc = await SetModel.findOne(
{ 'data.sealedProduct.uuid': uuid },
'data.code data.name data.releaseDate data.sealedProduct'
).lean();
if (!setDoc) {
throw new QuickAddError('Sealed product not found in catalog', 404);
}
const setData = setDoc.data;
const product = (setData.sealedProduct || []).find(p => p.uuid === uuid);
if (!product) {
throw new QuickAddError('Sealed product not found in catalog', 404);
}
const name = product.name || 'Unknown';
const category = categorizeProduct(name);
const tcgplayerProductId = product.identifiers?.tcgplayerProductId;
const imageUrl = tcgplayerProductId
? `https://product-images.tcgplayer.com/fit-in/400x400/${tcgplayerProductId}.jpg`
: null;
const slugifiedName = name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const sku = `sealed-${setData.code.toLowerCase()}-${slugifiedName}`;
const descriptionParts = [];
descriptionParts.push(`<h3>${name}</h3>`);
descriptionParts.push(`<p><strong>Set:</strong> ${setData.name} (${setData.code})</p>`);
descriptionParts.push(`<p><strong>Product Type:</strong> ${CATEGORY_LABELS_SERVER[category] || category}</p>`);
if (product.subtype) {
descriptionParts.push(`<p><strong>Subtype:</strong> ${product.subtype}</p>`);
}
if (setData.releaseDate) {
const releaseDate = new Date(setData.releaseDate);
descriptionParts.push(`<p><strong>Release Date:</strong> ${releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>`);
}
const publicationIds = await shopifyAPI.resolveConfiguredPublicationIds(store?.salesChannelConfig);
const mongoProduct = {
title: name,
handle: sku,
body: descriptionParts.join('\n'),
vendor: 'Wizards of the Coast',
type: 'Sealed Product',
tags: `sealed, ${setData.code}, ${category}`,
published: true,
variantprice: price,
variantsku: sku,
variantinventorypolicy: 'deny',
imagesrc: imageUrl,
imagealttext: name,
metafields: {
set_code: setData.code,
category,
release_date: setData.releaseDate || '',
uuid,
managed_by: MANAGED_BY_VALUE
}
};
let shopifyProduct;
try {
shopifyProduct = await shopifyAPI.createProduct(mongoProduct, publicationIds);
} catch (shopifyErr) {
logger.error('Sealed product quick-add: failed to create Shopify product', {
shop, uuid, name, error: shopifyErr.message
});
throw new QuickAddError('Failed to create product in Shopify. Nothing was saved β try again.');
}
const shopifyProductId = shopifyProduct.id;
const shopifyVariantId = shopifyProduct.variants?.edges?.[0]?.node?.id || null;
// Persist the SealedProduct doc as soon as the Shopify product exists,
// BEFORE attempting the inventory add. A failure in the inventory step
// below must never leave a live Shopify product with no corresponding
// doc β that orphan would look "not yet imported" on the next request
// and get created a SECOND time in Shopify. Quantity is never
// persisted (Shopify inventory is the source of truth), so nothing
// about this doc depends on the inventory step succeeding. (Fixes the
// orphan/duplicate-product bug found in Task 3's review.)
const sealedProduct = await SealedProduct.create({
shop,
uuid,
name,
category,
setCode: setData.code,
setName: setData.name,
releaseDate: setData.releaseDate ? new Date(setData.releaseDate) : null,
identifiers: {
tcgplayerProductId: tcgplayerProductId ? parseInt(tcgplayerProductId) : undefined,
cardmarketProductId: product.identifiers?.cardMarketId ? parseInt(product.identifiers.cardMarketId) : undefined,
mcmId: product.identifiers?.mcmId ? parseInt(product.identifiers.mcmId) : undefined
},
purchaseUrls: product.purchaseUrls || {},
currentPrice: price,
pricingSource: 'manual',
lastPriceUpdate: new Date(),
status: 'active',
requiresReview: false,
reviewedBy: shop,
reviewedAt: new Date(),
shopifyProductId,
shopifyVariantId,
syncStatus: 'synced',
lastSyncedAt: new Date()
});
let quantityAdded = 0;
let quantityWarning = null;
if (quantity > 0 && shopifyVariantId) {
try {
const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(shopifyVariantId);
const locationId = await shopifyAPI.getDefaultLocationId();
if (!locationId || !inventoryItemId) {
throw new Error('No active Shopify location found');
}
await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
quantityAdded = quantity;
} catch (invErr) {
logger.warn('Sealed product quick-add: product created but inventory add failed', {
shop, uuid, error: invErr.message
});
quantityWarning = 'Product was created and published, but adding inventory failed β add it manually or retry.';
}
}
return { created: true, product: sealedProduct, priceUpdated: false, quantityAdded, quantityWarning };
}
/**
* POST /api/sealed-products/quick-add
* Create-and-publish (or top up) a single sealed product in one step. See
* docs/superpowers/specs/2026-07-13-sealed-quick-add-design.md.
*
* Body: { uuid, quantity, price }
*/
router.post('/sealed-products/quick-add', validate(quickAddSchema), async (req, res) => {
try {
const { uuid, quantity, price } = req.body;
const result = await quickAddSealedProduct({
shop: req.shop, store: req.store, accessToken: req.accessToken, uuid, quantity, price
});
logger.info('Sealed product quick-add succeeded', { shop: req.shop, uuid, ...result, product: undefined });
res.json(result);
} catch (error) {
const status = error instanceof QuickAddError ? error.status : 500;
if (status === 500) {
logger.error('Sealed product quick-add failed', { error: error.message, shop: req.shop });
}
res.status(status).json({ error: error.message || 'Failed to add sealed product' });
}
});
/**
* POST /api/sealed-products/quick-add-bulk
* Same as quick-add, applied to a batch of products (the "By Release" bulk
* sync button). Processed sequentially β Shopify rate limits, same reason
* sealed-products/import processes sequentially.
*
* Body: { items: [{ uuid, quantity, price }, ...] }
*/
router.post('/sealed-products/quick-add-bulk', validate(quickAddBulkSchema), async (req, res) => {
const { items } = req.body;
const shop = req.shop;
const store = req.store;
const accessToken = req.accessToken;
let added = 0;
let updated = 0;
let failed = 0;
const results = [];
for (const item of items) {
try {
const result = await quickAddSealedProduct({ shop, store, accessToken, ...item });
if (result.created) added++; else updated++;
results.push({ uuid: item.uuid, ok: true, ...result, product: undefined });
} catch (error) {
failed++;
results.push({ uuid: item.uuid, ok: false, error: error.message || 'Failed to add sealed product' });
}
}
logger.info('Sealed product quick-add-bulk completed', { shop, added, updated, failed, total: items.length });
res.json({ added, updated, failed, results });
});This replaces the single-route version from Task 3 β the net result after this task is: QuickAddError class + quickAddSealedProduct() helper + both routes, with no duplicated business logic between them.
- [ ] Step 2: Add the schema import (if not already present from Task 3's Step 1)
Confirm quickAddBulkSchema is in the ../schemas destructure at the top of server/routes/api.js (added in Task 3 Step 1 alongside quickAddSchema).
- [ ] Step 3: Sanity-check the route parses
Run: node -e "require('./server/routes/api.js')" Expected: no output.
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Add POST /api/sealed-products/quick-add-bulk endpoint"Task 5: Server β suggestedPrice/pricingSource on GET /catalog/sealed β
Files:
- Modify:
server/routes/api.js
Interfaces:
Produces:
GET /catalog/sealed(MTG branch only β Riftbound branch is untouched, out of scope) now returns each product insealedProductswith two additional fields:suggestedPrice: number|null,pricingSource: string|null. Consumed by Task 8'sSealedQuickAddControls(to prefill the price field) and Task 9/10's Browse/By Release views.[ ] Step 1: Add the enrichment step
In server/routes/api.js, in the router.get('/catalog/sealed', ...) handler, locate:
javascript
const sealedProducts = await SetModel.aggregate(pipeline).allowDiskUse(true);
res.json({
sealedProducts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});
} catch (error) {
logger.error('Failed to fetch catalog sealed products', { error: error.message });Replace with:
javascript
const sealedProducts = await SetModel.aggregate(pipeline).allowDiskUse(true);
// Suggested price for the popover's prefill (market price, MSRP
// fallback) β same helpers the preview/import routes already use.
// This route runs before dualModeAuth is registered (no auth
// required to browse the catalog), so req.store is undefined here;
// applySealedPricingRules already falls back to
// DEFAULT_SEALED_PRICING_CONFIG when store is undefined. The
// authenticated quick-add/quick-add-bulk routes apply the store's
// real pricing config when the merchant doesn't override the price.
const pricePromises = sealedProducts.map((product) => {
const category = categorizeProduct(product.name || 'Unknown');
return getSealedProductPrice(product.uuid, product.setCode, category);
});
const priceResults = await Promise.all(pricePromises);
sealedProducts.forEach((product, index) => {
const priceResult = priceResults[index];
product.suggestedPrice = priceResult.price ? applySealedPricingRules(priceResult.price, req.store) : null;
product.pricingSource = priceResult.source || null;
});
res.json({
sealedProducts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});
} catch (error) {
logger.error('Failed to fetch catalog sealed products', { error: error.message });- [ ] Step 2: Sanity-check the route parses
Run: node -e "require('./server/routes/api.js')" Expected: no output.
- [ ] Step 3: Commit
bash
git add server/routes/api.js
git commit -m "Add suggested price to GET /api/catalog/sealed for the quick-add popover"Task 6: Client β api.js quick-add methods β
Files:
- Modify:
client/src/utils/api.js
Interfaces:
Produces:
sealedProductsApi.quickAdd(uuid, quantity, price),sealedProductsApi.quickAddBulk(items). Removes:sealedProductsApi.approve,sealedProductsApi.reject,sealedProductsApi.bulkApprove(nothing calls these after Task 9 removesSealedReviewManage; the underlying server routes stay, per Global Constraints β just no longer reachable from this client).[ ] Step 1: Update
sealedProductsApi
In client/src/utils/api.js, replace:
javascript
// Sealed Products API
export const sealedProductsApi = {
preview: (uuids) => api.get('/sealed-products/preview', { params: { uuids: uuids.join(',') } }),
import: (setCodes) => api.post('/sealed-products/import', { setCodes }),
importByUuids: (uuids) => api.post('/sealed-products/import', { uuids }),
list: (params = {}) => api.get('/sealed-products', { params }),
get: (id) => api.get(`/sealed-products/${id}`),
approve: (id, price) => api.put(`/sealed-products/${id}/approve`, { price }),
reject: (id) => api.put(`/sealed-products/${id}/reject`),
bulkApprove: (data) => api.post('/sealed-products/bulk-approve', data),
getPricingConfig: () => api.get('/sealed-pricing-config'),
updatePricingConfig: (config) => api.put('/sealed-pricing-config', config),
};with:
javascript
// Sealed Products API
export const sealedProductsApi = {
list: (params = {}) => api.get('/sealed-products', { params }),
get: (id) => api.get(`/sealed-products/${id}`),
quickAdd: (uuid, quantity, price) => api.post('/sealed-products/quick-add', { uuid, quantity, price }),
quickAddBulk: (items) => api.post('/sealed-products/quick-add-bulk', { items }),
getPricingConfig: () => api.get('/sealed-pricing-config'),
updatePricingConfig: (config) => api.put('/sealed-pricing-config', config),
};(preview/import/importByUuids are dropped too β nothing calls them once Task 9 removes the checkbox+preview-modal flow.)
- [ ] Step 2: Verify no other client code references the removed methods
Run: grep -rn "sealedProductsApi\.\(preview\|import\|importByUuids\|approve\|reject\|bulkApprove\)" client/src Expected: no matches other than inside CatalogSealedPage.jsx/CatalogSealedPage.test.jsx (updated in Tasks 9β11 β if this task runs before those, matches there are expected and will be cleaned up in those later tasks, not a failure of this task).
- [ ] Step 3: Commit
bash
git add client/src/utils/api.js
git commit -m "Replace sealed product approve/reject/bulk-approve API calls with quick-add"Task 7: Client β useSealedQuickAdd hook β
Files:
- Create:
client/src/hooks/useSealedQuickAdd.js - Create:
client/src/hooks/useSealedQuickAdd.test.js
Interfaces:
Consumes:
sealedProductsApi.quickAdd,sealedProductsApi.quickAddBulk(Task 6)Produces: default export
useSealedQuickAdd()returning{ getDraft(rowKey, product), setDraftField(rowKey, product, field, value), quickAddState, add(product, rowKey), addBulk(products, rowKeyFor), reset }. Named exports:DEFAULT_SEALED_DRAFT,defaultDraftFor(product),isArmed(draft). Consumed bySealedQuickAddControls(Task 8) andCatalogSealedPage.jsx(Tasks 9β10).[ ] Step 1: Write the failing tests for the pure helpers
Create client/src/hooks/useSealedQuickAdd.test.js:
javascript
import { describe, it, expect } from 'vitest';
import { DEFAULT_SEALED_DRAFT, defaultDraftFor, isArmed } from './useSealedQuickAdd';
describe('defaultDraftFor', () => {
it('defaults quantity to 0 and price to the product suggested price', () => {
const draft = defaultDraftFor({ suggestedPrice: 24.99 });
expect(draft.quantity).toBe('0');
expect(draft.price).toBe('24.99');
});
it('defaults price to empty string when there is no suggested price', () => {
const draft = defaultDraftFor({ suggestedPrice: null });
expect(draft.price).toBe('');
});
});
describe('isArmed', () => {
it('is false for the default (untouched) draft', () => {
expect(isArmed(DEFAULT_SEALED_DRAFT)).toBe(false);
});
it('is true once a quantity greater than zero is set', () => {
expect(isArmed({ ...DEFAULT_SEALED_DRAFT, quantity: '3' })).toBe(true);
});
it('is false when quantity is explicitly zero', () => {
expect(isArmed({ ...DEFAULT_SEALED_DRAFT, quantity: '0' })).toBe(false);
});
});- [ ] Step 2: Run tests to verify they fail
Run: npm run test:client -- useSealedQuickAdd Expected: FAIL β cannot find module ./useSealedQuickAdd
- [ ] Step 3: Implement the hook
Create client/src/hooks/useSealedQuickAdd.js:
javascript
import { useState, useCallback } from 'react';
import { sealedProductsApi } from '../utils/api';
// quantity is a string (controlled-input value, like useCardSyncAction's
// DEFAULT_CARD_SYNC_DRAFT) β '0' means "no stock change", not "unset".
export const DEFAULT_SEALED_DRAFT = { quantity: '0', price: '' };
export const defaultDraftFor = (product) => ({
quantity: '0',
price: product?.suggestedPrice != null ? String(product.suggestedPrice) : '',
});
// A draft is "armed" (shown as an active add, not just a price prefill)
// once the merchant has set a quantity greater than zero.
export const isArmed = (draft) => !!draft.quantity && Number(draft.quantity) > 0;
/**
* Shared quick-add state + actions for the sealed products Browse and By
* Release views: owns each row's quantity/price draft and the in-flight
* status of its quick-add call. Mirrors useCardSyncAction's shape
* (getDraft/setDraftField/reset) so the sealed and singles catalogs read
* the same way, even though sealed also carries a price field and can
* create a brand-new Shopify product (singles never does that from this
* control β see quickAddSealedProduct on the server).
*/
function useSealedQuickAdd() {
const [quickAddState, setQuickAddState] = useState({});
const [drafts, setDrafts] = useState({});
const getDraft = useCallback(
(rowKey, product) => drafts[rowKey] || defaultDraftFor(product),
[drafts]
);
const setDraftField = useCallback((rowKey, product, field, value) => {
setDrafts((prev) => ({
...prev,
[rowKey]: { ...(prev[rowKey] || defaultDraftFor(product)), [field]: value },
}));
}, []);
// Named `add` (not `quickAdd`) so call sites read as `quickAdd.add(...)`
// rather than stuttering `quickAdd.quickAdd(...)` β `quickAdd` is the
// hook instance itself, everywhere it's used as a variable name.
const add = useCallback(
async (product, rowKey) => {
const draft = drafts[rowKey] || defaultDraftFor(product);
const quantity = Number(draft.quantity) || 0;
const price = Number(draft.price) || 0;
setQuickAddState((prev) => ({ ...prev, [rowKey]: 'adding' }));
try {
await sealedProductsApi.quickAdd(product.uuid, quantity, price);
setQuickAddState((prev) => ({ ...prev, [rowKey]: 'added' }));
} catch (error) {
console.error('Failed to quick-add sealed product:', error);
setQuickAddState((prev) => ({ ...prev, [rowKey]: 'error' }));
}
},
[drafts]
);
// Bulk variant for the By Release "Sync N" button: only rows with a
// quantity > 0 are included, matching the spec's "fill in as you unbox"
// behavior β a row left at its price-only default doesn't get synced.
const addBulk = useCallback(
async (products, rowKeyFor) => {
const items = [];
const rowKeys = [];
for (const product of products) {
const rowKey = rowKeyFor(product);
const draft = drafts[rowKey] || defaultDraftFor(product);
const quantity = Number(draft.quantity) || 0;
if (quantity <= 0) continue;
items.push({ uuid: product.uuid, quantity, price: Number(draft.price) || 0 });
rowKeys.push(rowKey);
}
if (items.length === 0) return { added: 0, updated: 0, failed: 0, results: [] };
rowKeys.forEach((rowKey) => setQuickAddState((prev) => ({ ...prev, [rowKey]: 'adding' })));
try {
const { data } = await sealedProductsApi.quickAddBulk(items);
const resultByUuid = new Map(data.results.map((r) => [r.uuid, r]));
products.forEach((product) => {
const rowKey = rowKeyFor(product);
const result = resultByUuid.get(product.uuid);
if (!result) return;
setQuickAddState((prev) => ({ ...prev, [rowKey]: result.ok ? 'added' : 'error' }));
});
return data;
} catch (error) {
console.error('Failed to bulk quick-add sealed products:', error);
rowKeys.forEach((rowKey) => setQuickAddState((prev) => ({ ...prev, [rowKey]: 'error' })));
throw error;
}
},
[drafts]
);
const reset = useCallback(() => {
setQuickAddState({});
setDrafts({});
}, []);
return { getDraft, setDraftField, quickAddState, add, addBulk, reset };
}
export default useSealedQuickAdd;- [ ] Step 4: Run tests to verify they pass
Run: npm run test:client -- useSealedQuickAdd Expected: PASS (5 tests)
- [ ] Step 5: Commit
bash
git add client/src/hooks/useSealedQuickAdd.js client/src/hooks/useSealedQuickAdd.test.js
git commit -m "Add useSealedQuickAdd hook for sealed product quick-add state"Task 8: Client β SealedQuickAddControls popover β
Files:
- Create:
client/src/components/SealedQuickAddControls.jsx
Interfaces:
- Consumes:
draft({ quantity, price }from Task 7'sgetDraft),onChange(field, value),onAdd(),disabled,product(forproduct.name/product.uuidin labels/ids). - Produces: a
Popover-based+control, structurally identical in behavior toCardSyncControls.jsxbut with Quantity + Price fields instead of Quantity/Condition/Finish. Used bySealedBrowse/SealedByRelease(Tasks 9β10).
No dedicated test file β matches the CardSyncControls.jsx precedent (untested; only its hook is). Verified manually in Task 12.
- [ ] Step 1: Implement the component
Create client/src/components/SealedQuickAddControls.jsx:
jsx
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button, Popover } from '@/components/retroui';
/**
* "Add to inventory" control for a sealed product row on the Browse/By
* Release views β modeled directly on CardSyncControls.jsx (the singles
* catalog's equivalent). The key difference from singles: sealed products
* can be created here for the first time (not just topped up), so this
* carries a Price field the singles control doesn't need β the merchant
* confirms or overrides the suggested market/MSRP price before the
* product goes live.
*/
function SealedQuickAddControls({ product, draft, onChange, onAdd, disabled }) {
const [open, setOpen] = useState(false);
const handleAddClick = () => {
onAdd();
setOpen(false);
};
const quantity = Number(draft.quantity) || 0;
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button
size="sm"
variant={quantity > 0 ? 'default' : 'outline'}
disabled={disabled}
className="px-2 gap-1"
title={quantity > 0 ? `Will add ${quantity} to inventory at $${draft.price || 0}` : 'Add to inventory'}
aria-label="Add to inventory"
>
<Plus className="h-3.5 w-3.5" />
{quantity > 0 && <span className="text-xs font-semibold">{quantity}</span>}
</Button>
</Popover.Trigger>
<Popover.Content className="w-56 space-y-3">
<div>
<p className="font-head text-sm font-bold uppercase">Add to Inventory</p>
<p className="text-xs text-muted-foreground">
Price is pre-filled from market/MSRP β change it if you want.
Quantity ADDS to existing stock; it never sets a total.
</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground" htmlFor={`price-${product.uuid}`}>
Price
</label>
<div className="flex items-center gap-1">
<span className="text-sm font-bold">$</span>
<input
id={`price-${product.uuid}`}
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={draft.price}
onChange={(e) => onChange('price', e.target.value)}
className="w-full px-2 py-1 text-sm border-2 border-input rounded shadow-sm focus:outline-none"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground" htmlFor={`qty-${product.uuid}`}>
Units to add
</label>
<div className="flex items-center gap-1">
<span className="text-sm font-bold">+</span>
<input
id={`qty-${product.uuid}`}
type="number"
min="0"
max="500"
placeholder="0"
value={draft.quantity}
onChange={(e) => onChange('quantity', e.target.value)}
className="w-full px-2 py-1 text-sm border-2 border-input rounded shadow-sm focus:outline-none"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t-2 border-black/10">
<Button size="sm" onClick={handleAddClick} disabled={disabled} className="px-3">
{disabled ? 'Addingβ¦' : quantity > 0 ? `Add ${quantity}` : 'Save'}
</Button>
</div>
</Popover.Content>
</Popover>
);
}
export default SealedQuickAddControls;- [ ] Step 2: Manual smoke check
This component isn't wired into a page until Task 9 β defer visual verification to Task 12 (it will exercise this component as part of the full page).
- [ ] Step 3: Commit
bash
git add client/src/components/SealedQuickAddControls.jsx
git commit -m "Add SealedQuickAddControls popover for sealed product inventory"Task 9: Client β rewire SealedBrowse, remove Review & Manage β
Files:
- Modify:
client/src/pages/catalog/CatalogSealedPage.jsx
Interfaces:
Consumes:
useSealedQuickAdd(Task 7),SealedQuickAddControls(Task 8).Produces:
CatalogSealedViewnow switches between'browse'and'byRelease'(no more'review');SealedBrowseuses quick-add instead of checkbox-select + preview-modal import.[ ] Step 1: Remove the Review & Manage tab and section
Replace:
jsx
const sealedSupported = selectedGame === 'mtg' || selectedGame === 'riftbound';
const importSupported = selectedGame === 'mtg';
const [section, setSection] = useState('browse'); // 'browse' | 'review'with:
jsx
const sealedSupported = selectedGame === 'mtg' || selectedGame === 'riftbound';
const importSupported = selectedGame === 'mtg';
const [section, setSection] = useState('browse'); // 'browse' | 'byRelease'Replace the button row + section switch:
jsx
{importSupported && (
<Card className="p-3 sm:p-4">
<div className="flex gap-2 flex-wrap">
<Button
variant={section === 'browse' ? 'default' : 'outline'}
onClick={() => setSection('browse')}
className="flex-1 sm:flex-none min-w-[120px]"
>
Browse & Import
</Button>
<Button
variant={section === 'review' ? 'default' : 'outline'}
onClick={() => setSection('review')}
className="flex-1 sm:flex-none min-w-[120px]"
>
Review & Manage
</Button>
</div>
</Card>
)}
{section === 'review' && importSupported ? (
<SealedReviewManage />
) : (
<SealedBrowse selectedGame={selectedGame} selectable={importSupported} />
)}with:
jsx
{importSupported && (
<Card className="p-3 sm:p-4">
<div className="flex gap-2 flex-wrap">
<Button
variant={section === 'browse' ? 'default' : 'outline'}
onClick={() => setSection('browse')}
className="flex-1 sm:flex-none min-w-[120px]"
>
Browse & Import
</Button>
<Button
variant={section === 'byRelease' ? 'default' : 'outline'}
onClick={() => setSection('byRelease')}
className="flex-1 sm:flex-none min-w-[120px]"
>
By Release
</Button>
</div>
</Card>
)}
{section === 'byRelease' && importSupported ? (
<SealedByRelease />
) : (
<SealedBrowse selectedGame={selectedGame} quickAddEnabled={importSupported} />
)}(SealedByRelease is added in Task 10 β this task will not compile/render standalone until then; that's fine, Tasks 9 and 10 land together before Task 12's manual verification. If executing via subagent-driven-development with a review gate between tasks, note this dependency to the reviewer.)
- [ ] Step 2: Replace
SealedBrowse's selection/import state with quick-add
Replace the whole SealedBrowse function (from function SealedBrowse({ selectedGame, selectable }) { through its closing } β currently spans the checkbox/preview-modal logic) with:
jsx
function SealedBrowse({ selectedGame, quickAddEnabled }) {
const [sealedProducts, setSealedProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 });
const [viewMode, setViewMode] = useState('grid');
const [searchValue, setSearchValue] = useState('');
const [setFilter, setSetFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [subtypeFilter, setSubtypeFilter] = useState('');
const [sortBy, setSortBy] = useState('releaseDate'); // releaseDate, name
const quickAdd = useSealedQuickAdd();
const loadSealedProducts = useCallback(async (page) => {
setLoading(true);
try {
const params = { page, limit: 50, game: selectedGame };
if (categoryFilter && selectedGame === 'mtg') {
params.category = categoryFilter;
}
const { data } = await api.get('/catalog/sealed', { params });
setSealedProducts(prev => (page === 1 ? data.sealedProducts : [...prev, ...data.sealedProducts]));
setPagination(data.pagination);
} catch {
// Error loading sealed products
} finally {
setLoading(false);
}
}, [selectedGame, categoryFilter]);
useEffect(() => {
loadSealedProducts(1);
}, [loadSealedProducts]);
// Client-side filtering and sorting
const filteredProducts = sealedProducts.filter(product => {
const matchesSearch = !searchValue ||
product.name?.toLowerCase().includes(searchValue.toLowerCase()) ||
product.setName?.toLowerCase().includes(searchValue.toLowerCase());
const matchesSet = !setFilter ||
product.setName?.toLowerCase().includes(setFilter.toLowerCase()) ||
product.setCode?.toLowerCase().includes(setFilter.toLowerCase());
const matchesSubtype = !subtypeFilter || product.subtype === subtypeFilter;
return matchesSearch && matchesSet && matchesSubtype;
});
const sortedProducts = [...filteredProducts].sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (sortBy === 'releaseDate') {
const dateA = new Date(a.releaseDate || '2000-01-01');
const dateB = new Date(b.releaseDate || '2000-01-01');
return dateB - dateA; // newest first
}
return 0;
});
const categories = [...new Set(sealedProducts.map(p => p.category).filter(Boolean))];
const subtypes = [...new Set(sealedProducts.map(p => p.subtype).filter(Boolean))];
return (
<Card className="p-3 sm:p-4">
<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">
{selectedGame === 'mtg' ? 'Sealed Products' : `${selectedGame.toUpperCase()} Sealed Products`}
</Text>
<Text as="p" className="text-xs sm:text-sm text-muted-foreground">
{quickAddEnabled
? 'Browse booster boxes, decks, and bundles. Use the + on any product to set a price and add it to your inventory.'
: 'Browse sealed products like booster boxes, decks, and bundles.'}
</Text>
</div>
<div className="h-0.5 bg-muted" />
{/* Filters Row */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input
type="text"
placeholder="Search by name..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-full pl-9 pr-8 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-card text-foreground"
/>
{searchValue && (
<button
onClick={() => setSearchValue('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
Γ
</button>
)}
</div>
</div>
<div className="min-w-[160px]">
<div className="relative">
<input
type="text"
placeholder="Filter by set..."
value={setFilter}
onChange={(e) => setSetFilter(e.target.value)}
className="w-full px-3 pr-8 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-card text-foreground"
title="e.g., Lorwyn, LRW, Eclipsed"
/>
{setFilter && (
<button
onClick={() => setSetFilter('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
Γ
</button>
)}
</div>
</div>
<div className="min-w-[150px]">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-card text-foreground"
>
<option value="">All Types</option>
{categories.map(cat => (
<option key={cat} value={cat}>{categoryLabel(cat)}</option>
))}
</select>
</div>
<div className="min-w-[150px]">
<select
value={subtypeFilter}
onChange={(e) => setSubtypeFilter(e.target.value)}
className="w-full px-3 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-card text-foreground"
>
<option value="">All Subtypes</option>
{subtypes.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
<div className="min-w-[130px]">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full px-3 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-card text-foreground"
>
<option value="releaseDate">Newest First</option>
<option value="name">Name (A-Z)</option>
</select>
</div>
</div>
{/* Count row */}
<div className="flex flex-wrap justify-between items-center gap-2">
<Text as="span" className="text-xs sm:text-sm text-muted-foreground">
Showing {sortedProducts.length} of {pagination.total} products
</Text>
<div className="flex gap-1.5 sm:gap-2">
<Button
size="sm"
variant={viewMode === 'grid' ? 'default' : 'outline'}
onClick={() => setViewMode('grid')}
className="text-xs sm:text-sm"
>
Grid View
</Button>
<Button
size="sm"
variant={viewMode === 'list' ? 'default' : 'outline'}
onClick={() => setViewMode('list')}
className="text-xs sm:text-sm"
>
List View
</Button>
</div>
</div>
{loading && sealedProducts.length === 0 ? (
<div className="flex items-center gap-2 py-4">
<div className="w-4 h-4 border-2 border-muted border-t-black rounded-full animate-spin" />
<Text className="text-sm">Loading sealed products...</Text>
</div>
) : sortedProducts.length === 0 ? (
<div className="text-center py-8">
<Text as="p" className="text-muted-foreground">
{sealedProducts.length === 0 ? 'No sealed products found.' : 'No products match your filters.'}
</Text>
</div>
) : viewMode === 'grid' ? (
<SealedProductsGridView products={sortedProducts} quickAddEnabled={quickAddEnabled} quickAdd={quickAdd} />
) : (
<SealedProductsListView products={sortedProducts} quickAddEnabled={quickAddEnabled} quickAdd={quickAdd} />
)}
{pagination.page < pagination.pages && (
<div className="flex justify-center">
<Button
onClick={() => loadSealedProducts(pagination.page + 1)}
disabled={loading}
variant="outline"
>
{loading && <span className="w-4 h-4 border-2 border-muted border-t-black rounded-full animate-spin mr-2" />}
Load More
</Button>
</div>
)}
</div>
</Card>
);
}- [ ] Step 2: Add the quick-add control to the grid card
In SealedProductCard (kept, but its props change), replace the {/* Selection control / imported state */} block:
jsx
{/* Selection control / imported state */}
{selectable && (
<div className="absolute top-2 right-2">
{isImported ? (
<Badge variant="success" className="text-xs">Imported</Badge>
) : (
<label className="flex items-center justify-center bg-card border-2 border-black rounded p-1 cursor-pointer">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(product.uuid)}
aria-label={`Select ${product.name}`}
/>
</label>
)}
</div>
)}with:
jsx
{/* Quick-add control */}
{quickAddEnabled && (
<div className="absolute top-2 right-2">
{quickAddState === 'added' ? (
<Badge variant="success" className="text-xs">Added β</Badge>
) : (
<SealedQuickAddControls
product={product}
draft={quickAdd.getDraft(product.uuid, product)}
onChange={(field, value) => quickAdd.setDraftField(product.uuid, product, field, value)}
onAdd={() => quickAdd.add(product, product.uuid)}
disabled={quickAddState === 'adding'}
/>
)}
</div>
)}And update SealedProductCard's signature and its caller (SealedProductsGridView) β replace:
jsx
function SealedProductsGridView({ products, selectable, selectedUuids, importedUuids, onToggleSelect }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{products.map((product, idx) => (
<SealedProductCard
key={product.uuid || idx}
product={product}
selectable={selectable}
isSelected={!!product.uuid && selectedUuids.has(product.uuid)}
isImported={!!product.uuid && importedUuids.has(product.uuid)}
onToggleSelect={onToggleSelect}
/>
))}
</div>
);
}
// Individual Sealed Product Card
function SealedProductCard({ product, selectable, isSelected, isImported, onToggleSelect }) {with:
jsx
function SealedProductsGridView({ products, quickAddEnabled, quickAdd }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{products.map((product, idx) => (
<SealedProductCard
key={product.uuid || idx}
product={product}
quickAddEnabled={quickAddEnabled}
quickAdd={quickAdd}
quickAddState={product.uuid ? quickAdd.quickAddState[product.uuid] : undefined}
/>
))}
</div>
);
}
// Individual Sealed Product Card
function SealedProductCard({ product, quickAddEnabled, quickAdd, quickAddState }) {- [ ] Step 3: Add a price display to the grid card
The card currently has no price display at all (unlike singles, which shows card.latestPrice). Add one, in the "Product Info" section, right after the set name line:
jsx
<Text as="p" className="text-xs text-muted-foreground">
{product.setName} ({product.setCode})
</Text>becomes:
jsx
<Text as="p" className="text-xs text-muted-foreground">
{product.setName} ({product.setCode})
</Text>
{product.suggestedPrice != null && (
<Text as="p" className="text-sm font-bold">
{formatPrice(product.suggestedPrice, 'β')}
</Text>
)}- [ ] Step 4: Update the list view the same way
SealedProductsListView's signature changes from { products, selectable, selectedUuids, importedUuids, onToggleSelect } to { products, quickAddEnabled, quickAdd }. Replace the selection <th>/<td> (the {selectable && <th ... />} header cell and its matching body cell with the checkbox/imported-badge) with a "Price" header column and, in the body, a cell rendering the same suggested price plus the quick-add control:
jsx
<tr>
{selectable && <th className="px-2 py-2 w-10 border-b-2 border-black" />}
<th className="px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Name</th>
<th className="px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Set</th>
<th className="hidden sm:table-cell px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Subtype</th>
<th className="hidden md:table-cell px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Release</th>
<th className="px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Links</th>
</tr>becomes:
jsx
<tr>
<th className="px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Name</th>
<th className="px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Set</th>
<th className="hidden sm:table-cell px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Subtype</th>
<th className="hidden md:table-cell px-2 sm:px-3 py-2 text-left font-semibold border-b-2 border-black">Release</th>
<th className="px-2 sm:px-3 py-2 text-right font-semibold border-b-2 border-black">Price</th>
<th className="px-2 sm:px-3 py-2 text-center font-semibold border-b-2 border-black">Add</th>
</tr>and the body row β replace:
jsx
{categoryProducts.map((p, idx) => {
const isImported = !!p.uuid && importedUuids.has(p.uuid);
const isSelected = !!p.uuid && selectedUuids.has(p.uuid);
return (
<tr key={p.uuid || idx} className={`border-b border-black/20 last:border-b-0 hover:bg-muted ${isSelected ? 'bg-secondary/30' : ''}`}>
{selectable && (
<td className="px-2 py-2 text-center">
{isImported ? (
<Badge variant="success" className="text-xs">β</Badge>
) : (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(p.uuid)}
aria-label={`Select ${p.name}`}
/>
)}
</td>
)}
<td className="px-2 sm:px-3 py-2 font-medium">{p.name}</td>with:
jsx
{categoryProducts.map((p, idx) => {
return (
<tr key={p.uuid || idx} className="border-b border-black/20 last:border-b-0 hover:bg-muted">
<td className="px-2 sm:px-3 py-2 font-medium">{p.name}</td>and after the existing "Links" <td> (which stays as-is), add:
jsx
<td className="px-2 sm:px-3 py-2 text-right font-medium">
{formatPrice(p.suggestedPrice, 'β')}
</td>
<td className="px-2 sm:px-3 py-2 text-center">
{quickAddEnabled && (
quickAdd.quickAddState[p.uuid] === 'added' ? (
<Badge variant="success" className="text-xs">β</Badge>
) : (
<SealedQuickAddControls
product={p}
draft={quickAdd.getDraft(p.uuid, p)}
onChange={(field, value) => quickAdd.setDraftField(p.uuid, p, field, value)}
onAdd={() => quickAdd.add(p, p.uuid)}
disabled={quickAdd.quickAddState[p.uuid] === 'adding'}
/>
)
)}
</td>- [ ] Step 5: Delete
SealedPreviewModalandSealedReviewManage
Delete the entire SealedPreviewModal function (the // PREVIEW MODAL... section header through its closing }) and the entire SealedReviewManage function (the // REVIEW & MANAGE... section header through the end of the file) β nothing references them anymore after Steps 1β4.
Also delete the module-level STATUS_BADGE_VARIANT constant (near the top of the file, next to CATEGORY_LABELS) β it was only read inside SealedReviewManage (<Badge variant={STATUS_BADGE_VARIANT[status]}>), so it becomes dead code once that function is gone:
jsx
const STATUS_BADGE_VARIANT = {
draft: 'warning',
active: 'success',
archived: 'gray',
};Delete this block entirely.
- [ ] Step 6: Update imports
At the top of the file, replace:
jsx
import { Alert, Badge, Button, Card, Checkbox, Text } from '@/components/retroui';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import api, { sealedProductsApi } from '../../utils/api';
import { formatPrice } from '../../utils/format';with:
jsx
import { Badge, Button, Card, Text } from '@/components/retroui';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import api from '../../utils/api';
import { formatPrice } from '../../utils/format';
import useSealedQuickAdd from '../../hooks/useSealedQuickAdd';
import SealedQuickAddControls from '../../components/SealedQuickAddControls';(Alert/Checkbox/sealedProductsApi were only used by the removed preview-modal/checkbox/review-manage code; formatDate stays, since it's still used by SealedProductsListView's formatMonth... actually check: formatDate is used in SealedProductCard's release-date line and SealedPreviewModal's product list β confirm it's still referenced after Step 5's deletion before removing its usage. It is, in SealedProductCard, so it stays as a module-level function β no change needed there.)
- [ ] Step 7: Run the client build to catch anything missed
Run: npm run build Expected: exits 0. If it fails on an unused-var lint-as-error or a leftover reference to selectable/selectedUuids/importedUuids/SealedPreviewModal/SealedReviewManage, fix the reference (this file's structure means a few call sites are easy to miss on a manual edit β the build/lint step is the safety net).
- [ ] Step 8: Commit
bash
git add client/src/pages/catalog/CatalogSealedPage.jsx
git commit -m "Replace sealed products checkbox-import flow with per-product quick-add"Task 10: Client β SealedByRelease view β
Files:
- Modify:
client/src/pages/catalog/CatalogSealedPage.jsx
Interfaces:
Consumes:
GET /api/sets(existing endpoint, via plainapi.get) for the release dropdown;GET /catalog/sealed?sets=X(existing endpoint, extended in Task 5) for that release's products;useSealedQuickAdd,SealedQuickAddControls,SealedProductsGridView/SealedProductsListView(all already in this file after Task 9).Produces:
SealedByReleasecomponent, rendered byCatalogSealedViewwhensection === 'byRelease'(wired in Task 9 Step 1).[ ] Step 1: Implement
SealedByRelease
Add this new function to CatalogSealedPage.jsx, after SealedBrowse and before SealedProductsGridView (or anywhere else at module scope in this file β placement doesn't affect behavior, but keeping view-level components grouped together matches the file's existing organization).
This version fixes two bugs Task 10's review found in an earlier draft of this code: (1) re-clicking "Sync N" after a partial failure used to resubmit every already-synced row too β since Shopify inventory adds are additive, that silently double-added stock for products that had already succeeded; fixed by excluding rows with quickAddState[uuid] === 'added' from both the armed count and the bulk submission (syncableProducts). (2) loadProducts had no cancellation guard (unlike the releases-loading effect right above it) β switching the release dropdown twice quickly could let a stale response overwrite a newer one; fixed by applying the same cancelled-flag pattern:
jsx
// ============================================================
// BY RELEASE β pick one release, fill in quantities for its whole
// shipment, sync in one batch. Reuses the same grid/list rendering as
// Browse; only the filtering model (single release, not free-text) and
// the bulk sync action differ.
// ============================================================
function SealedByRelease() {
const [releases, setReleases] = useState([]); // [{ code, name, releaseDate }]
const [releasesLoading, setReleasesLoading] = useState(true);
const [selectedCode, setSelectedCode] = useState('');
const [products, setProducts] = useState([]);
const [productsLoading, setProductsLoading] = useState(false);
const [viewMode, setViewMode] = useState('grid');
const [syncFeedback, setSyncFeedback] = useState(null);
const [syncing, setSyncing] = useState(false);
const quickAdd = useSealedQuickAdd();
// Populate the release picker from GET /api/sets (limit 100, sorted by
// release date desc β recent releases first, which is what "the
// shipment that just arrived" means in practice). Filtered client-side
// to sets that actually have sealed products.
useEffect(() => {
let cancelled = false;
(async () => {
setReleasesLoading(true);
try {
const { data } = await api.get('/sets', { params: { limit: 100, sort: 'releaseDate', direction: 'descending' } });
if (cancelled) return;
const withSealed = (data.sets || []).filter(s => s.sealedProductCount > 0);
setReleases(withSealed);
if (withSealed.length > 0) setSelectedCode(withSealed[0].code);
} catch {
// Error loading releases
} finally {
if (!cancelled) setReleasesLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
// Cancellation guard matches the releases-loading effect above β without
// it, switching the release dropdown twice quickly could let an earlier
// request's response land after a later one and silently show the wrong
// release's products under a correctly-labeled dropdown (found in Task
// 10's review).
const loadProducts = useCallback((setCode) => {
if (!setCode) {
setProducts([]);
return () => {};
}
let cancelled = false;
setProductsLoading(true);
(async () => {
try {
const { data } = await api.get('/catalog/sealed', { params: { game: 'mtg', sets: setCode, limit: 100 } });
if (cancelled) return;
setProducts(data.sealedProducts);
} catch {
// Error loading release products
} finally {
if (!cancelled) setProductsLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
quickAdd.reset();
const cancelLoad = loadProducts(selectedCode);
return cancelLoad;
}, [selectedCode, loadProducts]);
// Excludes rows already synced this session β without this, re-clicking
// "Sync N" after a partial failure would resubmit every already-added
// row too, and since Shopify inventory adds are additive, that silently
// double-adds stock for products that already succeeded (found in Task
// 10's review).
const syncableProducts = products.filter(p => quickAdd.quickAddState[p.uuid] !== 'added');
const armedCount = syncableProducts.filter(p => {
const draft = quickAdd.getDraft(p.uuid, p);
return Number(draft.quantity) > 0;
}).length;
const handleSyncAll = async () => {
setSyncing(true);
setSyncFeedback(null);
try {
const result = await quickAdd.addBulk(syncableProducts, (p) => p.uuid);
setSyncFeedback({
type: result.failed > 0 ? 'warning' : 'success',
message: `Added ${result.added}, updated ${result.updated}, failed ${result.failed}`,
});
} catch (error) {
setSyncFeedback({ type: 'error', message: error.response?.data?.error || 'Sync failed' });
} finally {
setSyncing(false);
setTimeout(() => setSyncFeedback(null), 5000);
}
};
return (
<Card className="p-3 sm:p-4">
<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">By Release</Text>
<Text as="p" className="text-xs sm:text-sm text-muted-foreground">
Pick a release, fill in quantities and prices for its products, then sync the whole shipment at once.
</Text>
</div>
<div className="h-0.5 bg-muted" />
{syncFeedback && (
<Alert status={syncFeedback.type === 'success' ? 'success' : syncFeedback.type === 'warning' ? 'warning' : 'danger'}>
<Alert.Description>{syncFeedback.message}</Alert.Description>
</Alert>
)}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:items-center">
<div className="min-w-[220px]">
<select
value={selectedCode}
onChange={(e) => setSelectedCode(e.target.value)}
disabled={releasesLoading || releases.length === 0}
className="w-full px-3 py-2 text-xs sm:text-sm border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-card text-foreground"
>
{releases.length === 0 && <option value="">No releases with sealed products</option>}
{releases.map(r => (
<option key={r.code} value={r.code}>{r.name} ({r.code})</option>
))}
</select>
</div>
<div className="flex gap-1.5 sm:gap-2 ml-auto">
<Button
size="sm"
variant={viewMode === 'grid' ? 'default' : 'outline'}
onClick={() => setViewMode('grid')}
className="text-xs sm:text-sm"
>
Grid View
</Button>
<Button
size="sm"
variant={viewMode === 'list' ? 'default' : 'outline'}
onClick={() => setViewMode('list')}
className="text-xs sm:text-sm"
>
List View
</Button>
<Button
size="sm"
onClick={handleSyncAll}
disabled={syncing || armedCount === 0}
className="text-xs sm:text-sm"
>
{syncing ? 'Syncingβ¦' : `Sync ${armedCount}`}
</Button>
</div>
</div>
{productsLoading ? (
<div className="flex items-center gap-2 py-4">
<div className="w-4 h-4 border-2 border-muted border-t-black rounded-full animate-spin" />
<Text className="text-sm">Loading release products...</Text>
</div>
) : products.length === 0 ? (
<div className="text-center py-8">
<Text as="p" className="text-muted-foreground">
{releases.length === 0 ? 'No releases with sealed products found.' : 'This release has no sealed products.'}
</Text>
</div>
) : viewMode === 'grid' ? (
<SealedProductsGridView products={products} quickAddEnabled quickAdd={quickAdd} />
) : (
<SealedProductsListView products={products} quickAddEnabled quickAdd={quickAdd} />
)}
</div>
</Card>
);
}- [ ] Step 2: Restore the
Alertimport
Task 9 Step 6 removed Alert from the retroui import since SealedBrowse no longer uses it β but SealedByRelease (this task) does. Update the import line back to:
jsx
import { Alert, Badge, Button, Card, Text } from '@/components/retroui';- [ ] Step 3: Run the client build
Run: npm run build Expected: exits 0.
- [ ] Step 4: Commit
bash
git add client/src/pages/catalog/CatalogSealedPage.jsx
git commit -m "Add By Release view for bulk sealed product quick-add"Task 11: Client β update CatalogSealedPage.test.jsx β
Files:
- Modify:
client/src/pages/catalog/CatalogSealedPage.test.jsx
Interfaces:
Consumes: the rewritten
CatalogSealedPage.jsx(Tasks 9β10).[ ] Step 1: Write the updated test file
Replace the full contents of client/src/pages/catalog/CatalogSealedPage.test.jsx:
javascript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } 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() },
sealedProductsApi: {
quickAdd: vi.fn(),
quickAddBulk: 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.mockImplementation((url) => {
if (url === '/sets') {
return Promise.resolve({ data: { sets: [] } });
}
return Promise.resolve({ 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());
});
it('offers the Browse & Import / By Release toggle for MTG', async () => {
renderAt('mtg');
await waitFor(() => {
expect(screen.getByRole('button', { name: /Browse & Import/i })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /By Release/i })).toBeInTheDocument();
});
it('does not offer import/by-release controls for a catalog-only game (riftbound)', async () => {
renderAt('riftbound');
await waitFor(() => {
expect(api.get).toHaveBeenCalledWith('/catalog/sealed', expect.objectContaining({ params: expect.objectContaining({ game: 'riftbound' }) }));
});
expect(screen.queryByRole('button', { name: /By Release/i })).not.toBeInTheDocument();
});
it('switches to By Release and loads the release picker', async () => {
renderAt('mtg');
const byReleaseBtn = await screen.findByRole('button', { name: /By Release/i });
fireEvent.click(byReleaseBtn);
await waitFor(() => {
expect(api.get).toHaveBeenCalledWith('/sets', expect.objectContaining({ params: expect.objectContaining({ limit: 100 }) }));
});
expect(screen.getByText(/Pick a release/i)).toBeInTheDocument();
});
});- [ ] Step 2: Run the test file
Run: npm run test:client -- CatalogSealedPage Expected: PASS (6 tests)
- [ ] Step 3: Commit
bash
git add client/src/pages/catalog/CatalogSealedPage.test.jsx
git commit -m "Update CatalogSealedPage tests for quick-add and By Release"Task 12: Manual end-to-end verification β
Files: none (verification only β no code changes)
Per CLAUDE.md Β§7's sync-affecting-change bar and the "UI changes must be tested in a browser" rule, run this against a real dev store before considering the feature done.
- [ ] Step 1: Start the full stack
Run: docker compose up -d (MongoDB + Redis), then npm run dev (server + worker + client), and in a separate terminal ngrok http 3000 if testing embedded. Confirm all three processes report healthy startup with no errors.
- [ ] Step 2: Run the full test suites
Run: npm test (server) and npm run test:client (client) and npm run lint. Expected: all exit 0; zero new lint warnings compared to main.
- [ ] Step 3: Verify quick-add on a never-imported product
In the browser, navigate to Sealed β Browse & Import for MTG. Pick a product, open its + popover, confirm the price field is pre-filled with a non-blank suggestion where the catalog has pricing data, set a quantity, click Add. Confirm: the card shows "Added β", and in the Shopify admin the product exists, is Active (not Draft), has the chosen price, and has the chosen inventory quantity at the store's default location.
- [ ] Step 4: Verify quick-add on an already-imported product (top-up)
Quick-add the same product again with a different quantity and a changed price. Confirm in Shopify admin: the price updated to the new value, and inventory increased by the new quantity (added to the existing stock, not replaced).
- [ ] Step 5: Verify By Release
Switch to the By Release tab. Confirm the release dropdown defaults to a recent release and lists only releases with sealed products (cross-check one release's sealedProductCount via GET /api/sets response in the Network tab). Set quantities on 2β3 products in the selected release, leave one at zero, click "Sync N" (N should equal the count with quantity > 0, not the full product count). Confirm the zero-quantity product was not created/touched in Shopify, and the others were.
- [ ] Step 6: Verify Riftbound is unaffected
Switch the game selector to Riftbound β Sealed. Confirm: no "By Release" button, no + controls, browsing still works exactly as before this change.
- [ ] Step 7: Verify the old draft/approve routes still work (safety net)
curl or Postman against PUT /api/sealed-products/:id/approve for a manually-inserted status: 'draft' SealedProduct document (insert one directly via mongosh against the local dev DB, not production) β confirm it still approves and publishes correctly, proving the untouched legacy path still functions.
- [ ] Step 8: Final review pass
Invoke the game-parity skill's checklist mentally one more time: confirm this diff touches no server/plugins/, no per-game model, no per-game importer (sealed products aren't part of the plugin system) β nothing to mirror across MTG/Pokemon/Riftbound beyond what's already been explicitly scoped (MTG-only feature, Riftbound/Pokemon explicitly unaffected and manually confirmed in Steps 6β7... Pokemon has no sealed products at all, so no Pokemon-specific check applies).
Self-Review Notes β
Spec coverage:
- Quick-add popover with quantity + price, prefilled from market/MSRP β Tasks 3, 4, 7, 8, 9 β
- Never-imported creates-and-publishes; already-imported tops up β Tasks 3/4 (
quickAddSealedProduct) β - Review & Manage removed, checkbox+preview-modal import removed β Task 9 β
- Old draft/approve/reject/bulk-approve routes untouched β not modified anywhere in this plan; verified in Task 12 Step 7 β
- By Release: release dropdown + reused grid/list + bulk sync β Task 10 β
GET /catalog/sealedsuggestedPrice/pricingSource β Task 5 β- No SealedProduct schema changes β confirmed, no task touches
server/models/SealedProduct.jsβ - Riftbound/Pokemon unaffected β Task 12 Steps 6, 8 β
Placeholder scan: no TBD/TODO markers; every step has literal, complete code or an exact command with expected output.
Type consistency: quickAddSealedProduct({ shop, store, accessToken, uuid, quantity, price }) (Task 4) matches both call sites (single route and bulk loop) exactly. useSealedQuickAdd()'s returned shape (getDraft, setDraftField, quickAddState, add, addBulk, reset) matches every consumer in Tasks 9β10 (renamed from an earlier quickAdd/quickAddBulk draft to avoid the quickAdd.quickAdd(...) stutter against the hook-instance variable name β fixed consistently across Tasks 7, 9, 10). sealedProductsApi.quickAdd(uuid, quantity, price) / .quickAddBulk(items) (Task 6) β these keep their names since they hang off sealedProductsApi, not off a variable also named quickAdd β match the hook's internal calls (Task 7) and the server's expected body shape (Task 1's schemas).
Plan complete and saved to docs/superpowers/plans/2026-07-13-sealed-quick-add.md. Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?
