Appearance
Sealed Products Quick-Add โ Design โ
Problem โ
Merchant feedback (Jonathan + Brent, sealed products):
- Importing a sealed product takes four steps (select in Browse โ Preview modal โ Import as Shopify draft โ separately visit Review & Manage to set a real price and Approve). The singles catalog does this in one step: a
+popover lets the merchant set quantity/condition and sync immediately. - When a shipment arrives, the merchant wants to work through one release at a time โ see every sealed product in that release together and fill in quantities as they unbox, instead of hunting for individual products in a flat list.
- The Review & Manage screen ("acting funky since the UI rework") duplicates work the
+flow can do inline: since the merchant can already set the price and quantity before anything goes live, there's no separate approval step to perform.
Scope โ
Client (CatalogSealedPage.jsx and children) + two new server endpoints + one existing endpoint's response shape extended. No changes to SealedProduct's schema โ quantity is never persisted (Shopify inventory is the source of truth, same as the singles catalog), and currentPrice/pricingSource already exist.
Out of scope: the "Queue" โ "History/Queue" rename and under-market-price flagging from the same feedback thread โ separate, independently-scoped changes, not part of this spec.
Change 1: Replace Preview/Import/Review/Approve with a single quick-add action โ
Today, SealedBrowse selects products by checkbox, opens SealedPreviewModal to compute suggested pricing, and creates Shopify products as DRAFT with a system-suggested price (POST /sealed-products/import). A separate SealedReviewManage view (the "Review & Manage" tab) lists those drafts, lets the merchant override price, and calls PUT /sealed-products/:id/approve to publish (or /reject to archive).
This is replaced with a + popover on every product card/row โ SealedQuickAddControls, modeled directly on the singles catalog's CardSyncControls (client/src/components/CardSyncControls.jsx): a Quantity field ("add to stock" semantics โ never sets a total) and a Price field, pre-filled with the suggested price (market price, falling back to MSRP) and always editable. Clicking "Add":
- Product never imported (no
SealedProductdoc for{shop, uuid}): create the Shopify product published (ACTIVE, not draft) at the chosen price, set opening inventory to the chosen quantity, and create theSealedProductdoc directly withstatus: 'active',syncStatus: 'synced'. No draft stage. - Product already imported: add the quantity as a delta to existing Shopify inventory (via the same
shopifyAPI.addInventoryQuantityused by singles โ activates the location first if the variant was never stocked there, seeshopifyAPI.js:1028), and if the price differs fromcurrentPrice, update the Shopify variant price andcurrentPrice/pricingSource: 'manual'on theSealedProductdoc.
The "Review & Manage" tab, SealedReviewManage component, the checkbox-select + SealedPreviewModal import flow, and the client-side sealedProductsApi.approve/reject/bulkApprove calls are all removed.
What stays, unchanged, as a safety net: the draft status value on the model, and the PUT /sealed-products/:id/approve, PUT /sealed-products/:id/reject, and POST /sealed-products/bulk-approve routes/schemas. Nothing in the new UI creates a draft anymore, but if any store currently has pending draft sealed products, those routes still work exactly as today โ they're just no longer linked from the UI. This avoids a data migration and avoids the risk of stranding any real merchant's in-progress review queue.
Change 2: "By Release" browsing โ
CatalogSealedPage keeps two buttons under Sealed: Browse & Import and By Release. Both render the same underlying grid/list view (SealedProductsGridView / SealedProductsListView, extended with the new + control per card/row) โ they differ only in their default filter:
- Browse & Import: unchanged filters โ free-text search, free-text set filter, category, subtype, sort.
- By Release: a release dropdown (populated from the distinct
setCode/setNamepairs already present in the loaded/catalog/sealeddata, sorted by release date descending) replaces free-text set filtering as the primary control. Picking a release filters the same grid/list down to just that release's products.
A "Sync N products" bulk button appears once at least one row has a non-zero quantity entered; it calls the bulk endpoint (Change 3) for every row with quantity > 0, using each row's own price field (defaulted to suggested price, independently editable per row โ same as the single-item + popover).
Change 3: Server endpoints โ
POST /api/sealed-products/quick-add โ body { uuid, quantity, price } (Zod schema in schemas/sealedProducts.js, quantity: z.number().int().min(0), price: z.number().min(0)). Looks up the catalog product by uuid (same mtg_sets lookup used by import/preview today), then:
- Not yet imported โ create the Shopify product via
shopifyAPI.createProduct(...)withpublished: trueandvariantprice: price, thenshopifyAPI.getDefaultLocationId()+shopifyAPI.addInventoryQuantity(variant.inventoryItem.id, locationId, quantity)ifquantity > 0, thenSealedProduct.create({ ..., status: 'active', syncStatus: 'synced' }). - Already imported โ look up the existing
SealedProduct; ifprice !== currentPrice, update the Shopify variant price and savecurrentPrice/pricingSource: 'manual'; ifquantity > 0, calladdInventoryQuantitywith the existing variant's inventory item.
POST /api/sealed-products/quick-add-bulk โ body { items: [{ uuid, quantity, price }, ...] }. Same per-item logic as quick-add, processed sequentially (Shopify rate limits โ same reason sealed-products/import processes sequentially today). Returns { added, updated, failed, results: [...] }, mirroring the response shape of the existing bulk-approve endpoint.
GET /catalog/sealed (existing route, routes/api.js:1172) โ extend the aggregation's per-product projection to include suggestedPrice/pricingSource, computed via the same getSealedProductPrice + applySealedPricingRules helpers the preview endpoint already uses, scoped to the current page's products (โค50). This lets the + popover pre-fill a price without a separate round trip per card. Per CLAUDE.md ยง5.6, this is a lookup joined onto already-paginated/projected results, not an added $sort โ no aggregation-limit risk.
Error handling โ
- Quick-add, never-imported, Shopify creation fails: nothing is persisted (no
SealedProductdoc written) โ the popover shows an inline error and stays open for retry. This is simpler than today's import flow, which stores a doc withsyncStatus: 'failed'even on Shopify failure; that made sense when a separate Review screen existed to retry failed drafts, but with no such screen, a stranded failed-but-persisted doc would have no UI path to resolution. - Quick-add, already-imported: price update and inventory delta are two independent Shopify calls, applied sequentially (price first). If the inventory call fails after the price update succeeded, the error message says so explicitly (e.g. "Price updated, but failed to add inventory โ try again") rather than implying nothing happened.
- Quick-add-bulk: per-item try/catch, same as
bulk-approvetoday โ one item's failure doesn't stop the rest; failures are reported by uuid in the response for the client to surface per-row.
Testing โ
CatalogSealedPage.test.jsx: remove assertions covering the Review & Manage tab and the checkbox/preview-modal import flow; add coverage for the+popover (never-imported and already-imported paths) and the By Release tab's release-dropdown filtering + bulk sync button.- New
SealedQuickAddControls.test.jsxcovering the popover's armed/unarmed states and the quantity/price inputs (CardSyncControls.jsx, the component it's modeled on, has no existing test file to mirror โ this will be the first coverage of this popover pattern). - Server: new Zod schema tests for
quickAddSchema/quickAddBulkSchema. Route tests forquick-addcovering: never-imported success, already-imported price-only update, already-imported quantity-only update, Shopify creation failure (asserts noSealedProductdoc written), and inventory-adjustment failure after a successful price update. - No schema-shape round-trip test needed (CLAUDE.md ยง5.2) โ no new persisted sub-document fields are introduced.
SealedProduct.test.js(existing model test) is unaffected โ no model changes.
Out of scope โ
- No change to the
draft/approve/reject/bulk-approveserver routes or schemas โ kept working, unlinked from the UI. - No change to Riftbound's catalog-only sealed browsing (
importSupportedstaysselectedGame === 'mtg'only โ Riftbound has no import/quick-add). - No under-market-price flagging (separate feedback item, needs its own design).
- No "Queue" โ "History/Queue" rename (separate, unrelated UI change).
- No data migration for any pre-existing draft
SealedProductrecords.
