Appearance
Sealed Barcode Availability, Explained to Merchants โ Design โ
Date: 2026-07-31 Status: Approved Scope decision: Show, at sealed quick-add time in the catalog browser, whether a product will get a scannable barcode โ and when it won't, why. Three states, MTG only (the other games have no sealed listing to annotate). No Shopify admin block, no new page, no change to what gets synced.
Problem โ
A merchant quick-added Marvel Super Heroes Commander Deck โ Wakanda Forever (Collector's Edition), opened it in Shopify, and found an empty barcode field. Their reasonable question was "is this broken?", followed by "I question that is the correct UPC."
Both instincts were right, and answering them took a production database query plus external product lookups. The merchant had no way to reach that answer.
Today three very different situations produce an identical blank field:
| Situation | Will it ever get a barcode? |
|---|---|
| A real per-product UPC exists | Yes โ it ships |
| The feed's UPC is a bundle/case code shared across items | No, effectively never |
| The feed has no UPC for this product | Maybe, if upstream publishes one |
SealedProductBarcode.getBarcode deliberately collapses the last two into null (see 2026-07-30-sealed-upc-from-tcgcsv-design.md, "Uniqueness"). That is correct for the sync path โ the caller only needs a value or nothing โ but it means nothing downstream can distinguish "never" from "not yet".
Why this matters commercially: multi-item product lines are disproportionately affected. All four Marvel Collector's Edition decks share 195166313085; all ten Streets of New Capenna commander decks share 195166121468. A store heavy on commander decks will see mostly blank barcodes and reasonably conclude the feature is broken.
Why not the Shopify admin block โ
The confusion surfaces in Shopify admin, and this repo already ships an admin block (extensions/buylist-customer-orders/), so it was a real option. Rejected for now: it is a new extension surface needing deploy and approval, and it only helps merchants who go looking after being confused. Setting the expectation at add time prevents the confusion instead of explaining it afterward. The admin block stays available as a follow-up if merchants still ask.
Approach (chosen: A โ annotate the existing catalog rows) โ
- A. Annotate sealed rows in the catalog browser โ โ the one moment the merchant is already in our UI looking at that product. No new surface, no new navigation.
- B. Server logs only โ rejected: cheapest, but the merchant still sees an unexplained blank and has to open a support conversation. It answers our question, not theirs.
- C. Shopify admin block โ rejected for now, see above.
Section 1 โ One rule, one implementation โ
getBarcode already encodes the rule "a barcode is usable only if exactly one product holds it." The UI needs the same rule plus the reason. Writing the shared-detection logic a second time would be a ยง5.8 Duplicated Guard, so the relationship is inverted instead: the batch method becomes the single implementation and getBarcode becomes a thin caller.
New static on server/models/SealedProductBarcode.js:
js
getBarcodeStatuses(game, uuids)
-> Promise<Map<uuid, { status: 'available' | 'shared', barcode: string, sharedBy: number }>>1
2
2
A uuid absent from the returned Map has no row at all โ that is the 'unpublished' state, represented by absence rather than an entry so callers cannot confuse "no data" with "bad data".
game is required and throws when missing, matching the existing getBarcode contract (ยง5.5).
Refactored:
js
getBarcode(game, uuid)
-> const statuses = await this.getBarcodeStatuses(game, [uuid]);
const entry = statuses.get(uuid);
return entry && entry.status === 'available' ? entry.barcode : null;1
2
3
4
2
3
4
Behavior is unchanged and query count is unchanged (two either way).
Implementation: one find for the rows matching the uuids, then one aggregation counting holders of each distinct barcode found. $match precedes $group, there is no $sort, and sealedproductbarcodes is a plain collection, so ยง5.6 does not bite. Both queries are served by the existing {game, uuid} and {game, barcode} indexes.
Batched deliberately: a set page lists roughly 30 sealed products. Per-product resolution would be ~60 queries per page load; this is two regardless of page size.
Section 2 โ Server โ
server/routes/setDetails.js:262 maps MTG sealed products for GET /catalog/sets/:code?include=sealed. Two fields are added to each mapped product:
js
barcodeStatus: 'available' | 'shared' | 'unpublished',
barcodeSharedBy: number | undefined // count, only when status === 'shared'1
2
2
Resolved with a single getBarcodeStatuses(game, uuids) call over the page's uuids, before the .map.
The endpoint is public-facing catalog data and already returns product identifiers, so no new authorization concern. The actual shared UPC value is not returned โ see Section 4.
Per-game parity (ยง5.1): the Pokemon branch (setDetails.js:378-379) and the Riftbound branch both return sealedProducts: []; there is nothing to annotate. They are exempt because they have no sealed listing, not because the concept doesn't apply โ if sealed support lands for either, this field comes with it, and getBarcodeStatuses is already game-scoped.
Section 3 โ Client โ
SealedTableContent in client/src/components/CatalogSetBrowser.jsx:1022 renders each sealed row. It gains an inline note, shown only when barcodeStatus !== 'available':
Marvel Super Heroes Commander Deck โ Wakanda Forever (CE) $112.99 [Add]
โ No scannable barcode โ the manufacturer publishes one code
for all 4 products in this line
The Hobbit โ Play Booster Box $169.99 [Add]
(nothing shown)1
2
3
4
5
6
2
3
4
5
6
The happy path stays completely silent. Most rows are unaffected, so a badge on every row would be noise.
Copy, final:
| Status | Text |
|---|---|
shared | No scannable barcode โ the manufacturer publishes one code for all {n} products in this line. |
unpublished | No barcode published yet โ may appear in a future data update. |
available | (nothing rendered) |
Singular/plural on "products" is handled; barcodeSharedBy is always โฅ 2 when status is shared.
The distinction earns its place: shared means "print your own labels if you need scanning", unpublished means "check back". Collapsing them would leave the merchant unable to make that decision.
Components: use the retroui barrel and cn() per client/CLAUDE.md. Note that retroui variant vocabularies diverge between components โ a wrong variant name renders unstyled with no error โ so the chosen component's own variant map must be checked rather than copied from a sibling.
Section 4 โ What we deliberately do not show โ
The shared UPC itself is not surfaced. Displaying 195166313085 next to a "no barcode" message invites a merchant to paste it into Shopify manually, which would give four products the same barcode and break exactly the POS lookup this whole effort protects. The count communicates the situation without handing over the footgun.
Section 5 โ Testing โ
getBarcodeStatuses: returnsavailablefor a solely-held barcode;sharedwith the correctsharedBycount for a multi-holder barcode; omits uuids with no row; handles an emptyuuidsarray without querying; throws whengameis missing (ยง5.5).getBarcoderegression: still returns the barcode when unique andnullwhen shared. This guards the refactor โ it must be impossible to change sync behavior while editing the batch method.setDetails: the sealed response carriesbarcodeStatus, andbarcodeSharedByonly when shared. Extendsserver/routes/api.catalogSealed.test.js.- Client:
SealedTableContentrenders nothing foravailable, the shared copy with the right count, and the unpublished copy. Note a fresh worktree has noclient/node_modulesโ runnpm --prefix client installbeforenpm run test:client. - Coverage: every modified file โฅ70% on all four metrics, read from the per-file table. The threshold gate in
vitest.config.jsis a live ratchet set just under measured totals, so it fails on regression but does not enforce the documented 70% bar per file.
Section 6 โ Rollout โ
Single PR. Server and client ship together โ the field is consumed in the same change that adds it (ยง5.9), and the client tolerates its absence (barcodeStatus undefined renders nothing), so deploy ordering does not matter.
Explicitly out of scope โ
- A Shopify admin block on the product page.
- Surfacing this anywhere in the sync flow or sync history.
- Any merchant-entered barcode field โ Shopify's own barcode field already persists merchant edits permanently, since nothing re-pushes a sealed barcode after product creation.
- Finding a better UPC source for the suppressed 36%. GCI-DB was investigated and is a manufacturer-facing B2B platform with no public API and no confirmed barcode data; it would need a commercial conversation, not an integration.
