Summary
Inside the mobile app, a signed-in scout can open a WebView (scout://store-webview,
mobile/app/store-webview.tsx) onto a separate route tree of the same
Next.js storefront project (store/app/app/…, base path /app) rather than
the public shopfront documented in
commerce-and-store.md. This doc covers only that
/app tree and the backend modules that feed it —
store-my-patches, store-collections (as consumed here), store-orders,
hat-favorites, and the Shopify calls that back the Promo/Swag shelves. It
does not re-derive the coming-soon wall, the earned-not-bought gate
mechanics, the /auth/native token handoff, or the RevenueCat dead code —
those are covered end-to-end in commerce-and-store.md and cross-referenced
below rather than repeated.
The /app home page (store/app/app/page.tsx:63-149) is one server
component that renders, top to bottom: a progress header, a "Your sets"
shelf (finished/near-finished collections at a $5-per-patch bundle price),
a "Promo" shelf, a "Swag" shelf, and a "Your patches" tap-to-add grid of
every patch the scout owns. A patch added from the grid always costs its own
live Shopify variant price ($8, one at a time) — that per-patch path never
gets a bundle discount, no matter how the collection's own product is
priced. The set price itself, however, is no longer purely display
arithmetic: a completed collection's own Shopify product is a real,
buyable line, gated by the completed-set purchase gate (canBuySet/
evaluateSetCompletion, store/lib/store/set-gate.ts, mirrored on the
backend at backend/src/store-collections/set-purchase-gate.ts) and priced
by sync-collection-bundle-pricing.ts to patchCount × $5. Applied to
production on 2026-09-02: 197 products repriced, 0 failed, so the price
behind the button is now real. See commerce-and-store.md's "Edge cases" for
the full state of that rollout, including the one residual — the storefront
still displays the stale Collection.patchCount, so eight collections
quote a price above what they charge.
Status (shipped / flagged off)
| Surface | State | Flag / gate | Shipped default |
|---|---|---|---|
In-app store home (/app) |
Shipped | Requires a signed-in session; unauthenticated visitors are redirected to /auth/sign-in?next=/app (store/app/app/page.tsx:64-68) |
On |
| Set shelf ($5/patch bundle, completed-set gate) | Shipped — real product, real gate, price live in prod | No flag. SetShelf.tsx/CollectionSetOffer.tsx display collectionBundlePrice(); the collection's OWN Shopify product is the thing actually bought, gated by canBuySet/evaluateSetCompletion and only offered as AddToCartButton once every sellable member is owned (see "How it works"). Repricing that product to patchCount × $5 is sync-collection-bundle-pricing.ts, and it has been applied to production: 197 collection products repriced, 0 failures, 2026-09-02, with a second run reporting 0 updates (see commerce-and-store.md). The fourteen store-exclusive packs stay at a flat $8 by decision. PatchAddGrid's per-tile addLine() (adding member patches one at a time) never gets a discount regardless |
Gate: on. Bundle price: live since 2026-09-02 |
| Promo / Swag shelves | Shipped | No feature flag — gated entirely by Shopify product tags/types (store/lib/store/promo.ts, store/lib/store/swag.ts) |
On |
storefront_curated_mvp (30-patch curated catalog) |
Does not apply here — and has no consumer anywhere | The app-flag registry entry is read by nothing in store/ or backend/src (grep, 2026-10-29); the store reads a different key, limited_inventory_mode, from the separate store_feature_flags table, and that only shapes the public sitemap.xml/robots.txt (store/app/sitemap.ts:102, store/app/robots.ts:10). The in-app "Your sets" shelf reads GET /api/store/collections directly with no curated filter (store-collections.controller.ts:15-19). Both keys read false in production (2026-10-29); the old CatalogPage this row used to cite no longer exists |
N/A to this surface |
hat_favorites / Hat Studio backend module |
Built, no live caller | No flag — the module (backend/src/hat-favorites/) has no importer anywhere under store/app, store/components, or store/lib today; it backed the deleted Hat Studio design tool |
Orphaned, not reachable from /app or anywhere else in the current tree |
store-orders (order history) |
Backend live, not linked from /app |
GET /api/store/orders is JWT-authed and working, but nothing under store/app/app/** links to it — the order-history page lives at /account/orders on the public shopfront tree, which is walled behind coming-soon and not reachable from the WebView's /app root |
Reachable only by direct URL, not by any tap target in this surface |
Rewards (/app/rewards) |
Shipped | No feature flag; JWT-authed via the same getCurrentUser()/getAccessToken() gate as /app. Full feature (ledger, minting, redemption) in rewards.md |
On — since the 2026-10-19 repricing the referral ladder pays one reward: free_earned_patch (any earned patch, free) at 10 qualified friends; pct_10_off/pct_20_off/patch_bogo (the 2026-09-22 rungs), free_scout_patch, free_snapback and snapback_bogo are admin-grant-only, on no rung — codes already issued still work. Percentage rewards are scoped to the patches-and-patch-hats Shopify collection, created in production 2026-09-22 — see rewards.md's Edge cases |
| Cart rewards carousel | Shipped | No feature flag; requires a signed-in session — GET /api/cart-rewards 401s and the cart shows no carousel when signed out. Shared component (CartDrawer.tsx) with the public shopfront (commerce-and-store.md) |
On, everywhere the cart drawer opens |
User-facing surfaces
/app(store/app/app/page.tsx) — the store home described in the screenshot: progress header, "Your sets", "Promo", "Swag", "Your patches"./app/collections/[slug](store/app/app/collections/[slug]/page.tsx) — tapping a set card. Shows "Full set value" (CollectionSetOffer.tsx) plus every member patch as an add-to-cart tile (owned patches addable, unowned ones dimmed/locked,PatchAddGrid+patch-tile-state.ts)./app/products/[handle](store/app/app/products/[handle]/page.tsx) — tapping a Promo or Swag card. Reuses the samePatchDetail/CollectionDetailcomponents as the public shopfront, just with the/appheader/chrome (no site nav, no JSON-LD,store/app/app/products/[handle]/page.tsx:18-22)./app/patch/[handle](store/app/app/patch/[handle]/page.tsx) — the patch detail Shop tab and "Purchase Patch" land here: the patch plus every product tagged to it. No sign-in redirect; an earn-to-buy patch you have not collected shows the locked callout. Seepatch-shop.md.- Entry point:
scout://store-webview(mobile deep link), opened from the drawer's Store row, the patch action sheet's "Purchase Patch" button and the patch detail Shop tab (flagbuy_patch— default off, and off in production:falseonGET /api/feature-flags, verified 2026-11-06; historytrue09-13,false10-11,true10-15 and 10-29 — seepatch-shop.md) — all detailed incommerce-and-store.md's "In-app WebView + secure session handoff" section. The celebration screen no longer has an entry point (removed 2026-10-11). The drawer's Store row routes to/app(the store home); Shop tab / Purchase Patch →/app/patch/<handle>(patchShopPath,mobile/src/config/store.ts:148), the per-patch page described inpatch-shop.md. The screen ismobile/app/store-webview.tsx; its route takes a singlepathparam; there is nonameparam any more, and no caller passes one. Withbuy_patchoff (production today; in store builds since v1.1.117), every one of these, the drawer's Store row included, opens the paused store's "would you buy this?" questionnaire instead of the WebView (store-interest.md). - The WebView is full-screen and carries NO native header. It starts just
below the top safe-area inset (
ScreenBackground edges={['top']}) and the storefront's ownAppStoreHeaderis the first thing on screen. The app used to stack a second bar ("✕ Store") above it, restating what the page below already said. The back-swipe is disabled (gestureEnabled: false, declared on thestore-webviewStack.Screeninmobile/app/_layout.tsx) — an edge swipe inside a scrolling storefront was closing the store by accident. Every exit now routes through a confirm prompt; see "The native close bridge" below. /app/rewards(store/app/app/rewards/page.tsx) — a scout's earned rewards (Available / Used / Expired), and the one place a reward's single-use Shopify discount code can be applied to the cart. Reached via a second, separate deep link/drawer row from the rest of this tree:scout://rewards(mobile/app/rewards.tsx) redirects into the same store-webview machinery, but atstoreHref('/rewards')rather than the store home, and the drawer's Rewards row (mobile/src/components/navigation/drawerSections.ts:269, brass count badge fromGET /api/rewards/unclaimed-count) is distinct from the Store row a few lines above it, not a sub-item of it. Full mechanics — the ledger, Shopify minting, redemption — are documented in rewards.md, not here; this doc covers only the/appstorefront surface.- No order-history or account surface is reachable from inside
/app.AppStoreHeader(store/components/store/AppStoreHeader.tsx) carries only navigation and the cart, and what it carries depends on where it is running:- In a browser (someone signed in at
shop.scout-patches.com/app) it is exactly what it always was — wordmark or back-chevron + title on the left, cart on the right. - Inside the app the left slot is the close control on every route
(
CloseStoreButton), the back-chevron is suppressed, and back moves into the page body asAppStoreBackLink("Back to your store"). Which controls render is decided byheaderControls()instore/lib/store/native-host.ts, where it is unit-tested.
- In a browser (someone signed in at
How it works (device → API → DB → response)
The page load
AppStorePage (store/app/app/page.tsx:63-104) is a single server
component. On each request it:
- Resolves the signed-in user + access token via
getCurrentUser()/getAccessToken()(cookie-based session set by the/auth/nativehandoff — seecommerce-and-store.md); redirects to sign-in if missing. - Fetches, in parallel: the scout's collected patches (
GET /api/store/my-patches, JWT-authed,store/app/app/page.tsx:26-36), Shopify swag products (getSwag()), Shopify promo products (getPromos()), and everystoreVisibleDB collection with full patch thumbnails (getStoreCollections({ allPatches: true })). - Resolves the collected patches' product handles against Shopify
(
resolveProductsByHandles, batched 50 at a time,store/lib/shopify/resolve-by-handles.ts:16-33) to get live price/stock/ image for the "Your patches" grid. - Filters collections with
appEarnableCollections(collections)(page.tsx:113,store/lib/store/app-earnable-collections.ts:36), which excludes bothpublicPurchaseandadminOnly. Store-exclusive bundles (bought outright, no earning required) never appear in "Your sets" — those live on the public shopfront — and neither do the 15 legacy admin-only mega-collections a scout could never finish. This is the shelf-side door of the four described incommerce-and-store.md.
1. Progress header — patches earned / sets started / ready or nearly done
StoreProgressHeader (store/components/store/StoreProgressHeader.tsx), a client
component fed by useOwnedHandles():
- Patches earned =
handles.length, computed server-side inpage.tsx:79asuniqueProductHandles(collected)— the count of distinct Shopify product handles among the scout'sUserPatchrows (store/lib/store/owned-handles.ts:23-33). Patches collected without a product handle (no Shopify product yet) don't count, and a patch earned twice (two separate unlock events) counts once. - Sets started = the number of earnable collections where at least one
member handle is owned (
StoreProgressHeader.tsx:41-43). - The third stat is dynamic:
done = completedSets(...).length; if it's> 0the label reads "ready now" and showsdone. Otherwise the label reads "nearly done" and showsclose = nearlySets({ within: 2 }).length(StoreProgressHeader.tsx:44-45; a two-tone progress bar below it,progressBar()instore/lib/store/store-progress.ts, rendersdoneandcloseas a share ofstarted). - Completion and "nearly" are measured against the SELLABLE member count,
not
Collection.patchCount. This inverts an earlier design:earned-sets.ts's own comment records that it used to gate onpatchCountspecifically to stop a mostly-unpublished collection from completing off one lucky handle, but that broke any collection with a member missing a Shopify product instead — it could never reach 100% no matter what the shopper collected.completedSets/nearlySets(store/lib/store/earned-sets.ts:75-89,127-149) now measure againstmemberHandles.length(the distinct sellable members,completedSetsatearned-sets.ts:80-91,nearlySetsatearned-sets.ts:133-150) — the same denominatorcanBuySet/evaluateSetCompletionuse for the purchase gate itself, so the header, the shelf, and the actual buy button agree on one number. "Sellable" here means "currently has a live Shopify product," not "designed to never be sold." The owner confirmed 2026-09-22 that every patch is meant to be orderable once a scout unlocks it — there is no class of patch that's permanently exempt.earned-sets.ts's own header comment still cites the old example ("Texas Hill Country: 11 members, 4 sellable") as a worked case for why gating on the rawpatchCountwas wrong; that example itself is now stale as a piece of catalog trivia (Texas Hill Country got a product on 2026-09-02, percommerce-and-store.md) but the underlying mechanism it illustrates is still correct and still runs. What's real today: 16 live patches across the catalog currently lack a Shopify product and so cannot be added to a cart or count toward a set's sellable total until one is created for them — a data-completeness gap to close, not a permanent design limit. Do not describe any specific patch as "not for sale"; describe the gap as "doesn't have a store listing yet."
2. "Your sets" shelf — one list, nearest-to-done first, with a real buy button
SetShelf (store/components/store/SetShelf.tsx):
- No tabs. An earlier "Ready to order" / "Nearly there" tab pair was
replaced with a single list ordered by proximity (
orderSets(),store/lib/store/set-shelf.ts) — the tabs' only failure mode was a tab whose sole job was to display zero when nothing was close. - Ready =
completedSets()— every sellable member collected. Nearly =nearlySets({ within: NEARLY_WITHIN }),NEARLY_WITHIN = 2(SetShelf.tsx:28) — missing 1 or 2 sellable patches. A collection with zero collected patches appears on neither list; 3+ missing → absent too. - Price:
collectionBundlePrice(row.patchCount)(note: pricing still uses the collection's rawpatchCount, not the sellable-member gate denominator — the physical set genuinely has that many patches even if fewer are individually sellable) —full = patchCount × $8,discounted = patchCount × $5(store/lib/collection-pricing.ts:31-44).PATCH_PRICE_USD = 8andCOLLECTION_PATCH_PRICE_USD = 5are hardcoded constants (collection-pricing.ts:11,14), mirrored (not imported — seecommerce-and-store.md) intobackend/src/scripts/lib/bundle-pricing.ts, which is what actually reprices the Shopify product this shelf links to. - The buy button is real, not just a link-through. Once a set is
doneand its collection product resolves to a live variant (productByHandle, resolved server-side and passed in as a prop —SetShelf.tsx:230-240), the card renders a genuineAddToCartButton(SetShelf.tsx:333-354) computed bysetBuyState()(store/lib/store/set-shelf-buy-state.ts:24-30), which wraps the samecanBuySetgate the collection detail page uses. Adoneset with no resolvable product (the four collections with no Shopify product at all — seecommerce-and-store.md) falls back to a plain "View the set" link, never a dead "Order the set" affordance (shelfCardLabel(),set-shelf-buy-state.ts:74-82). - That button charges
patchCount × $5for real: the repricing script was run with--applyagainst production on 2026-09-02 (197 updated, 0 failed, and a second run reports 0 changes). The displayed total agrees with it: the card readsCollection.patchCount, which production recomputes globally inside every publish apply, so the size shown and the price charged come from the same membership. Seecommerce-and-store.md's "Edge cases" for why that column is trustworthy on prod and drifts locally. - The horizontal card rail (
Rail,SetShelf.tsx:199-212) is a CSSsnap-xscroller with "Swipe for N more" text below it when more than one card exists — not a carousel library, a plain overflow-x div with scroll-snap. - Card thumbnails (
Stack,SetShelf.tsx:57-89, renamed from an earlierFan): up to 4 earned-patch images plus dashed "?" placeholders for missing ones (FAN_LIMIT = 4,SetShelf.tsx:31). CollectionSetOffer.tsx(the same offer on a collection's own detail page,store/components/store/CollectionSetOffer.tsx) shows the identical price and completion copy, but is purely informational — it does not itself render a buy button. The actualAddToCartButtonfor that page lives one level up inCollectionBuyArea.tsx(seecommerce-and-store.md's "How it works").
3. Promo shelf
getPromos() (store/lib/shopify/apparel-catalog.ts:52-54) queries Shopify
Storefront API for products matching tag:promo -tag:internal
(store/lib/store/promo.ts:9-19). A product is "promo" purely by merchant
tagging in Shopify — any product type (a patch, a hat, a shirt) qualifies if
tagged promo and not internal. "2 unlocked" is not a per-item computed
gate — meta={${promos.length} unlocked} (page.tsx:123) is just the
count of whatever the query returned; nothing on this shelf checks individual
ownership. Everything the Promo query returns is shown to every signed-in
scout equally.
4. Swag shelf
getSwag() (apparel-catalog.ts:47-49) queries Shopify for
product_type:"T-Shirt" OR product_type:"Hats", excluding internal-tagged
products (store/lib/store/swag.ts:20,43-50). These are the Printify-assigned
product_type values, per an in-code comment
(swag.ts:9-11): "T-Shirt" → the Compass Logo Tee, "Hats" → the Compass
Snapback Trucker Cap. The Trail Hat is deliberately excluded from Swag —
its product_type is "Trail Hat", not in SWAG_PRODUCT_TYPES
(swag.ts:13, explicit comment).
"3 unlocked" (page.tsx:130) is, identically to Promo, just swag.length
— a raw count label, not a per-item unlock check. The file's own comment
states plainly: "Today both swag products are unlocked for everyone" —
"unlocked" here is presentational language for "available to buy," not a
computed earned/not-earned state like patches have.
Sizes/prices: toSizeOptions() reads every variant's price and
availability directly from Shopify (apparel-catalog.ts:65-76); the card
shows a $low–$high spread when sizes differ in price (SwagShelf.tsx:5-11,
matching the screenshot's "$24–$30" for the tee) or a single price when they
don't (the $30 cap, "one size").
5. "Your patches" tap-to-add grid
PatchAddGrid (store/components/store/PatchAddGrid.tsx:146-203) renders
every item in patchItems — every product the scout's UserPatch rows
resolved to on Shopify (page.tsx:70-100). Tapping the whole tile (not a
small button) calls addLine({variantId, productHandle, quantity: 1})
against a Zustand cart store persisted to localStorage
(store/lib/store/cart.ts:26-47) and shows a toast + a small particle burst
(PatchAddGrid.tsx:44-57). A sticky bottom bar with running item
count/subtotal and a "Checkout" button appears once anything is in the cart
(PatchAddGrid.tsx:175-201); "Checkout" opens the shared CartDrawer.
"TAP TO ADD · $8" (page.tsx:137) is a hardcoded literal string in the
JSX (see Configuration and flags). The amount actually charged per patch is
whatever product.priceRange.minVariantPrice resolves to from Shopify at
add time (store/lib/store-patches.ts:127 in toGalleryItems) — the label
and the real charge are two independent sources that merely agree today.
Checkout hand-off
Identical mechanism to the public shopfront (commerce-and-store.md
"Catalog and checkout"): CheckoutButton
(store/components/store/CheckoutButton.tsx:61-75) calls a cartCreate
mutation (store/lib/shopify/cart-actions.ts:52-104, attaching
scout_session_id/scout_user_id cart attributes) and does
window.location.href = result.checkoutUrl — inside the WebView, which has
originWhitelist={['https://*']} (mobile/app/store-webview.tsx:84), so the
navigation to Shopify's hosted checkout (checkout.scout-patches.com) works
in-place without leaving the WebView shell. No separate checkout code exists
for the in-app tree.
One exception skips the cart: a product page opened with ?buynow=1 shows
"Purchase for $X" and goes straight to a one-line checkout through the same
cartCreate (store/components/store/BuyNowButton.tsx,
store/lib/store/buy-now.ts), emptying the Scout cart once the checkout
exists. In this /app tree that is the patch page, and only for a patch the
shopper owns (store/components/store/PatchBuyArea.tsx:74-75; the app's Buy
button is what opens it — patch-shop.md "Buy now"). Since 2026-10-27
(5706da7c) the same param also works on the public merch page,
/products/<handle>?buynow=1, for a shop-tagged hat — MerchBuyArea reads
it client-side because that page is ISR-cached — so an ad can land a stranger
one tap from checkout; the patch-hat earn gate still wins
(store/components/store/MerchBuyArea.tsx:38-64, :104-135, :177-182;
commerce-and-store.md § Buy now). So "owned patches only" describes this
tree, not buy-now as a whole.
6. The cart's rewards carousel
Added 2026-09-22 alongside the referral ladder's store-promo repricing (see
referral-and-invite.md, rewards.md).
CartDrawer.tsx is shared between this /app tree (mounted from
AppStoreHeader.tsx) and the public shopfront (mounted from Header.tsx,
commerce-and-store.md) — the carousel below runs identically on both, this
doc just owns the writeup.
- The carousel.
CartRewards.tsxrenders a horizontal scroller of every rewardGET /api/cart-rewardsreturns for the signed-in shopper — issued, with a live code, not past expiry, and of a kind the server can still describe (usableRewards,store/lib/store/rewards.ts). Signed-out shoppers and shoppers with no rewards see nothing; the carousel does not render at all (rewards.length === 0short-circuitsCartRewards). Each card shows the reward's label, its expiry, and — viarewardCardState()(store/lib/store/reward-card-state.ts) — either what it would save on the cart right now ("Saves $X", or "(about)" when a scoped line is on sale and the saving is an estimate) or why it can't apply yet ("Add <the reward's noun> to use this" for a product-scoped reward with nothing in cart; "Add one more patch to use this" for a BOGO reward one item short of its minimum; "For patches and patch hats" as the generic fallback). An empty cart shows every reward's own description instead of a cart-shaped prompt, and every card is unselectable. - Which reward the cart actually applies.
chooseReward()(store/lib/store/reward-savings.ts) picks, among the rewards that currentlyapplies(scope matched, minimum met): the shopper's own pinned pick if it still applies, otherwise the single largest saving, ties broken by earliest expiry, then by id for determinism.appliedReward()(store/lib/store/cart-cookie.ts) then weighs that pick against an ad-link offer (?discount=CODE) on the same cart: a pinned reward always applies (the shopper chose it), but a reward the cart would pick on its own applies only when it saves strictly more than the ad offer — on a tie or a loss the ad code stays in the cookie and its row shows. One discount ever applies — this cart shows one discount row, matching the one-code-per-order rule every reward code is minted with (rewards.md). - The pin: a choice sticks for the session, from two places. Tapping a
carousel card (
onChoose→pinCartReward()) or tapping Apply to cart on the Rewards page (RewardCard.tsx,/app/rewards) both write the samesessionStoragekey (CART_REWARD_PIN_KEY,store/lib/store/ rewards.ts) — a choice made on the Rewards page is a choice, not merely a cookie write the drawer's own cookie effect would otherwise clobber on its next mount. Once pinned, that reward is preferred bychooseReward()for the rest of the session even if a bigger saving becomes available, and if the pinned reward doesn't currentlyapply(e.g. the cart emptied), the cart falls back to the best-of-the-rest until the pinned one qualifies again — the pin waits rather than being dropped. - The discount cookie follows the choice, and an ad-link code is never
lost to a reward.
cartCookieAction()(store/lib/store/cart-cookie.ts) decides andrunCartCookieAction()(store/lib/store/rewards.ts) writes the same non-httpOnlyscout_discountcookie ad traffic has used for months (rewards.md's How it works #9). When a reward is applied its code goes into the cookie; if the cookie held an ad-link code (anything that is not one of the shopper's own reward codes), that code is first stashed insessionStorage(AD_CODE_STASH_KEY), and the cart keeps pricing the ad offer from the stash (adCodeInPlay) so the comparison above still works while the reward holds the cookie. When no reward applies but the shopper holds reward codes, the cookie is cleared only if it holds one of those codes — and the stashed ad code is written back in its place (nextCookieState). A cookie that holds an ad code is left alone. One reward replacing another of the shopper's own codes stashes nothing. Limits: the stash is per tab (sessionStorage), so an ad code displaced in one tab is not restored in another; and "own codes" in the drawer are the shopper's usable rewards, so an already-spent code of theirs still sitting in the cookie is treated like an ad code. - A change of user resets the cart's rewards. On the same
scout:auth-changedeventOwnedHandlesProviderlistens to (store/lib/auth/auth-events.ts, emitted on sign-out fromHeader.tsxand on sign-in), the drawer appliesresetOnAuthChange()(store/lib/store/cart-rewards-auth.ts): the previous user's reward codes come out of the cookie (a stashed ad code is written back), the pin is dropped, and the cached['cart-rewards']and['reward-candidates']queries are reset. A sign-out that does NOT emit that event — the account page's own sign-out form — skips this, so the previous user's reward code can stay in the cookie until the drawer next runs; and a sign-out that happens before the reward list has loaded leaves the cookie alone (the drawer never knew the codes). - "One more to unlock."
oneMorePrompt()(store/lib/store/reward-suggestions.ts) looks for a reward that is one qualifying item away from applying — only possible for a reward with an order minimum (buy-one-patch-get-one) or a product scope (a kind like the Scout patch, scoped to one SKU); a percentage reward has no minimum and so never prompts, it simply applies to whatever already qualifies. Suggested patches are drawn only from patches the shopper has already unlocked (UnlockedPatch[], sourced fromGET /api/store/my-patches'scollectionsfield) — the store sells nothing else, so a locked patch is never offered as a suggestion, and a suggestion already in the cart or out of stock is filtered out too. Patches that share a Shopify collection with something already in the cart are ordered first (suggestionHandles); the message itself names that shared collection when one exists ("Add one more Boston patch — get one free.") and falls back to generic copy otherwise. Tapping a suggestion adds it to the cart directly from the carousel. - The retired "10% off 3-or-more" sitewide break is gone. That
quantity-based multi-buy discount (formerly
store/lib/store/multi-buy.tsand its cart-row wiring — the file no longer exists, deleted in34de4b4e) was retired in Shopify on 2026-09-22 and deleted from this codebase in the same change —cart-discount.ts's own comment records why: showing it any longer would have promised a saving checkout no longer granted. - One discount per order, and the biggest one wins — automatically. Three
things can discount a cart: a reward code, an ad-link launch code, and the
automatic bundle tiers (Route 66 tees and National Park beanies/truckers:
2 for $35, 3 for $50 —
store/lib/store/shelf-offer.ts). None of them combine: both automatic breaks carrycombinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }(set 2026-09-22), and so does every code. Shopify's checkout then keeps only the single biggest saving — measured with real carts on 2026-09-22: a $10 code beat 6% of $48; 6% of $240 beat the same code, which came backapplicable: falseand was not spent; 2 tees + 2 NP hats get only the hats' 7%. The drawer mirrors that rule so its total is the one checkout charges:bestShelfOfferpicks the one biggest break (they never stack),appliedRewardonly puts a reward code in the cookie when it strictly beats the break (pin or no pin — a reward that loses stays unspent for a later order, and its carousel card says "<break> saves more — kept for later"), andcartDiscountRow()shows exactly one row: the applied reward, else the bigger of the ad code and the break, else nothing. Cart lines carry their product's collection handles (GET_VARIANTS_BY_IDS) so a line qualifies by the same collection the Shopify discount targets. - "Add 2 more tees — 3 for $60." When the cart holds some but not
enough of a quantity break's items, and reaching it would beat the
discount the order already gets (
shelfOfferNudge), the drawer counts down to it ("Add 2 more tees", then "Add one more tee") over a swipeable row (offerNudgeCards,CartNudgeRow). It shows for everyone, signed in or not. Every card adds without leaving the cart (since 2026-10-07): the first adds another of the item already in the cart, in the size already chosen, in a tap; a one-variant product also adds in a tap; anything with a real choice opens an in-cart chooser under the row (CartQuickChooser), labelled from the product's own pickable options ("Pick a color" for the NP hats, whose one-value Size axis is not a choice; "Pick a size & color" for the Route 66 tees). The chooser reuses the product page'sVariantSwatches/SizePicker/AddToCartButtonoverVariantSelectionProvider, so Size is still never picked for the shopper — it stays blank ("Choose a size", disabled) unless their own remembered size is in stock. The one exception is a gated patch hat the shopper has not earned (isBuyable): the cart never sells around the gate, so that card still links to its product page (web only; inside/app, where the cart cannot link out, it is dropped). The chooser works in/apptoo, so the other items now show there. Known gap (2026-09-30): the drawer's policy lines include a barehref="/shipping-returns"(CartDrawer.tsx:464), and the same drawer is mounted in/app(AppStoreHeader.tsx:127), so that one link can leave the/apptree. - Suggestion products are only fetched when they could be used. The
Storefront lookup behind "one more to unlock" (
getRewardCandidates) is enabled only while the shopper holds a non-percentage reward that does not currently apply (needsSuggestionCandidates,store/lib/store/reward-suggestions.ts). - The carousel never breaks the cart.
GET /api/cart-rewards(store/app/api/cart-rewards/route.ts) returns 401-and-empty when signed out and empty-on-any-failure otherwise (a thrown fetch, a non-OK backend response), anduseCartRewards()setsretry: falseexplicitly — a slow or failing reward fetch degrades to "no rewards shown," never a stuck cart drawer.
The native close bridge
With the app's own header gone, the store has to be able to close itself, and the app has to be able to refuse. That is one message in one direction.
1. The app declares a capability, before the page's own scripts run.
mobile/app/store-webview.tsx passes
injectedJavaScriptBeforeContentLoaded={STORE_HOST_INJECTION}
(mobile/src/config/storeBridge.ts), which sets
window.__scoutStoreHost = { canClose: true } and then fires a
scout:host-ready event.
The event is not decoration. iOS runs this as a WKUserScript at document
start, genuinely before the page's own code — but Android has no true
document-start hook: react-native-webview evaluates the script from
onPageStarted, which can land after React has taken its post-hydration
snapshot. useNativeStoreHost subscribes to the event, so a late marker still
turns the button on instead of being missed for that whole page load.
The signal is deliberately NOT window.ReactNativeWebView. That object is
injected by react-native-webview in every app build ever shipped, including
every version released before this bridge existed — which ignore the message.
Keying the button off it would put a live-looking X inside every un-updated
install and have it do nothing. A marker only new builds inject means the
button appears exactly where something is listening, and older builds keep
their own native header with no X in the page.
2. The store decides whether to draw a close control.
hasNativeCloseHost(window) (store/lib/store/native-host.ts) requires
__scoutStoreHost.canClose === true — a strict identity check, not a
truthiness test, so a spoofed or malformed marker is refused. The components
read it through useNativeStoreHost(), a useSyncExternalStore whose server
snapshot is a flat false: window does not exist during SSR, and a hydration
mismatch on the one element the bar is arranged around would be the worst
possible place for one.
The arrangement itself is decided by headerControls({ hosted, showBack }) in
the same pure module, not inline in the components. The storefront's test
runner only picks up plain .ts files under lib/, and the project has no
component-test setup at all — so a branch decided inside a .tsx file is a
branch asserted by nothing. Keeping it here makes the load-bearing invariant
testable: when a sub-page asks for a back affordance, exactly one of the bar
chevron and the in-page link renders — never both, never neither.
3. The tap posts one message. postCloseStore(window) sends
{"type":"scout:close-store"} through window.ReactNativeWebView.postMessage,
and returns false rather than throwing if there is no bridge.
4. The app confirms, and only then dismisses. onMessage runs
isCloseStoreMessage(nativeEvent.data) — which takes unknown, never throws,
and answers false for malformed JSON, arrays, bare strings and any other
type — then raises a native Alert:
Leave the store? Anything in your cart stays there for next time. Stay · Leave
The prompt is unconditional. No cart state crosses the bridge, and a prompt
that appears only sometimes is one nobody learns to expect. The same
confirmClose is bound to Android's hardwareBackPress (returning true, so
the navigator cannot dismiss the store out from under the dialog) — the
hardware-back equivalent of the swipe being removed.
5. The app keeps its own exit for every page that cannot draw one.
The close control is the page's job now, and the WebView does not always show
one of our pages. isInAppStoreUrl(currentUrl, getStoreUrl()) watches
onNavigationStateChange, and the app renders its own
store-webview-fallback-close over anything that is not the /app tree. It
fails closed — unparseable, empty, or not-yet-loaded counts as "not the
store", so the exit exists from the first frame. A redundant button costs a
little chrome; a missing one traps somebody.
This is not belt-and-braces. Tapping Checkout runs
window.location.href = checkoutUrl (store/components/store/CheckoutButton.tsx),
navigating this WebView to Shopify's own domain, which renders no
AppStoreHeader and never will. With the native header removed and the
back-swipe disabled, iOS would otherwise have no exit from checkout at all.
The same guard covers the /auth/native silent-login hop and any external
link. The fallback still prompts — leaving mid-checkout is exactly when an
accidental tap costs the most.
The load-failure screen is the other exception. onError (transport
failure) and onHttpError (a 500/404, where the request succeeded and
Next renders its own error page with no header of ours) both raise the failure
screen, which carries its own store-webview-error-close beside Retry. That
one does not prompt — nothing loaded, so there is nothing to stay for.
Known gap: a page that returns 200 on our origin but whose client JS never
hydrates would show neither the store's X (it needs JS) nor the fallback (the
URL is the /app tree). Rare, and called out rather than papered over with a
watchdog timer.
Deploy ordering. The store should be deployed before an app build carrying this change reaches users. The capability marker is what makes that ordering safe in both directions: an old app never shows an X it cannot act on, and a new app pointed at an old store still has its own fallback exit for checkout plus its error screen.
The 2026-09-01 redesign
The screen's layout is unchanged — header, sets, promo, swag, your patches, in
that order. What changed is everything inside it, worked out in
store/research/app-store-mock.html:
- Header. The three-equal-boxes stat band is gone. It gave 138, 65 and 5 the
same weight in the same shape, so nothing was ranked and only one of the three
was ever actionable.
StoreProgressHeaderleads with patches earned, puts sets started and sets nearly-done in support, and adds a two-tone bar (brass = finished, ember = nearly) with a caption naming what to do next. - Product card. Promo and swag stopped drawing their own compact tile and
now render
GalleryPiece— the card the catalog and collection pages already use — through thetoSwagGalleryItemadapter that had existed, unwired, since it was written. They passvariant="field": no pedestal panel (these are transparent cutouts, so the panel only boxed in art designed to float), the price beside the name, and the add control as a disc on the art. Every otherGalleryPiececaller stays onvariant="gallery"and is untouched. - The favourite heart is deleted, not hidden. There is no favourites feature behind it. The lock badge keeps that corner — it marks an unearned patch.
- Rails are pagers.
scroll-snap-stop: alwaysforces the scroll to halt at the next card, so one swipe moves exactly one product instead of however many momentum carries. Cards sit at 91% with amask-imagefading the right edge. - Set cards are stacks. Owned patches overlap, a
+ncovers the ones past the fan's width so a ten-patch set is not implied to be four, then the dashed gap slot, then "N patches to go · X of Y" and the set price. - A promo product no longer fills the swag shelf too.
buildSwagQuerynow excludestag:promo. Promo is tag-based and holds any product type, so a promotional hat also matched the swag type clause and rendered on both shelves — pre-existing since495f4bf2, invisible while the tiles were small and obvious once the cards went full width. - Shelves lead with proof.
orderByProof(store/lib/store/shelf-order.ts) pulls well-reviewed products to the front of the promo and swag rails, so the card visible without swiping is the one carrying a star row. The bar is an average of 4.0 or better, not "has any review": a two-star average in the first slot is worse than no stars. Nothing is hidden — a poorly-rated product keeps its place in the rail and its reviews still render in full on its own page. Everything below the bar keeps Shopify's order, which the merchant controls. - The card's disc always renders, and its action follows what the product
can do: a single-variant patch adds to the cart, apparel opens the product
page to choose a size.
toSwagGalleryItemstill returnsdefaultVariantId: nullfor every swag product on purpose — quick-adding a tee would silently pick whichever size sorted first, and the card's toast is hardcoded to read "Iron-on patch". - The set tabs are gone. "Ready to order" / "Nearly there" existed so the shelf never rendered empty; one list ordered by proximity keeps that guarantee without a tab whose only job was to display a zero.
PatchAddGrid ("Your patches") is deliberately untouched — it is a
different component with a different job (bulk selection, not browsing).
Data model (Prisma, backend/prisma/schema.prisma)
UserPatch(line 785) —{userId, patchId, collectedAt, source}, unique on(userId, patchId); joined toPatchforproductHandle,patchUrl,city,state. The source of truth for "what has this scout collected."Patch.productHandle(line 117) — a patch with no handle never appears in "Your patches" (filtered out server-side,page.tsx:84).Collection.patchCount(line 46) — a stored/denormalized integer, not a live join count; the authoritative denominator for "completed" and "nearly there," deliberately not derived fromPatchCollectionmembership.Collection.publicPurchase(line 51) —trueexcludes a collection from "Your sets" entirely (store-exclusive bundle, not an earned set).Collection.storeVisible(line 48, defaulttrue) —falsemeans a collection never reachesGET /api/store/collections(store-collections.service.ts:174) and so can't appear here either.- No table stores a per-user "set is complete" flag — completion is
recomputed on every page load from
UserPatch+Collection.patchCount, client-side after hydration (useOwnedHandles()). StoreOrder,HatFavorite,WaitlistEntry(orphaned 2026-09-17, see below) — covered fully incommerce-and-store.md;HatFavoriteis confirmed here to have no caller anywhere in the current/app(or any other) storefront tree.
API surface
All under the backend NestJS app.
| Method & path | Auth | Purpose |
|---|---|---|
GET /api/store/my-patches |
JWT | The scout's own collected patches ({patchId, name, patchUrl, productHandle, city, state, collectedAt}[]), newest first — feeds "Your patches" and the PATCHES stat (store-my-patches.controller.ts:15-18, store-my-patches.service.ts:19-49) |
GET /api/store/collections?allPatches=true |
none | Every storeVisible collection with its full patch-thumbnail list — feeds "Your sets" (store-collections.controller.ts:15-19) |
GET /api/store/orders, GET /api/store/orders/:id |
JWT | A scout's own order history — backend exists but not linked from anywhere in /app (store-orders.controller.ts:16-29) |
GET /api/store/hat-favorites |
none | Lists Hat Studio combo rows — no caller in the current storefront (hat-favorites.controller.ts:26-30) |
POST/DELETE /api/store/hat-favorites |
StorefrontTokenGuard (shared secret, not a user session) |
Mutates Hat Studio combos — same, unreachable UI |
GET /api/store/feature-flags |
none | Store flag reads (limited_inventory_mode, storefront_hats_enabled, plus the app flag patch_club mixed in by getStorefrontFlags). Under /app only the Patch Club row on /app/patch/<handle> reads one (patch_club, store/app/app/patch/[handle]/page.tsx:54,66,89 — off in prod, patch-club.md). limited_inventory_mode only shapes the public sitemap/robots and storefront_hats_enabled is ignored by getHatsVisible() (store/lib/hats-visible.ts:23), so neither gates any catalog |
Next.js/Shopify (no separate backend route — direct Storefront API calls
from store/):
getPromos()/getSwag()(store/lib/shopify/apparel-catalog.ts:47-54) — Shopify product-listing queries by tag/type, 5-minute revalidate.resolveProductsByHandles()(store/lib/shopify/resolve-by-handles.ts:16-33) — batched (50/request) Shopify product lookups by handle.cartCreate(store/lib/shopify/cart-actions.ts:52-104) — same checkout mutation the public shopfront uses.
Key files
The native close bridge
-
mobile/app/store-webview.tsx— the full-screen WebView screen: capability injection,onMessage, the confirmAlert, the Android hardware-back subscription, the off-tree fallback close, and the failure screen. -
mobile/src/config/storeBridge.ts—STORE_HOST_INJECTION,CLOSE_STORE_MESSAGE,HOST_READY_EVENT,isCloseStoreMessage, andisInAppStoreUrl(which decides when the app must draw its own exit). Mirrors (does not import)store/lib/store/native-host.ts; the two must move together. -
mobile/app/_layout.tsx—gestureEnabled: falseon thestore-webviewStack.Screen. An in-route<Stack.Screen>alone does NOT register it. -
store/lib/store/native-host.ts—hasNativeCloseHost,headerControls,closeStorePayload,postCloseStore. Pure and framework-free so the node:test runner covers it. -
store/components/store/useNativeStoreHost.ts— the React binding (useSyncExternalStore, server snapshotfalse, subscribed toscout:host-ready). -
store/components/store/CloseStoreButton.tsx— the close control,nullin a browser. -
store/components/store/AppStoreBackLink.tsx— "Back to your store", the in-page replacement for the header chevron inside the app. -
mobile/research/store-webview-chrome-lab.html— the four close-control arrangements this design was chosen from, and what each one cost. -
store/app/app/page.tsx— the store-home server component; owns data fetching and section ordering. -
store/app/app/layout.tsx— the/app-only layout: no site header/footer,StoreBasePathProviderpinned to/appso every card link stays inside the tree. -
store/components/store/StoreProgressHeader.tsx— the progress-led header. -
store/lib/store/store-progress.ts— its caption/kicker/bar maths, unit-tested. -
store/lib/store/set-shelf.ts— set ordering + stack counts, unit-tested. -
store/components/store/SetShelf.tsx— "Your sets" stack cards + pager rail. -
store/lib/store/earned-sets.ts—completedSets/nearlySets, the pure logic behind Ready/Nearly and both stat-band counts. -
store/lib/collection-pricing.ts— the $8/$5 bundle math (PATCH_PRICE_USD,COLLECTION_PATCH_PRICE_USD,collectionBundlePrice), mirrored (not imported) intobackend/src/scripts/lib/bundle-pricing.ts, which drives the actual Shopify repricing. -
store/components/store/CollectionSetOffer.tsx— the "Full set value" card on a collection's own detail page (/app/collections/[slug]), same price math, informational only — the real buy control on that page isCollectionBuyArea.tsx(seecommerce-and-store.md). -
store/lib/store/set-gate.ts,backend/src/store-collections/set-purchase-gate.ts—canBuySet/evaluateSetCompletion, the completed-set purchase gate (identical predicate on both sides). -
store/lib/store/set-shelf-buy-state.ts— the shelf's buy-or-locked decision (setBuyState) and its "Order the set" vs. "View the set" label logic (shelfCardLabel). -
store/lib/shopify/apparel-catalog.ts,store/lib/store/swag.ts,store/lib/store/promo.ts— Promo/Swag product selection by Shopify tag/type. -
store/components/store/SwagShelf.tsx— shared pager rail for both Promo and Swag. -
store/components/store/PatchAddGrid.tsx,store/lib/store/patch-tile-state.ts— the tap-to-add patch grid and its addable/locked/sold-out state machine. -
store/lib/store/cart.ts,store/lib/shopify/cart-actions.ts,store/components/store/CheckoutButton.tsx— cart state and the shared checkout hand-off to Shopify. -
store/lib/store-patches.ts(toGalleryItems),store/lib/shopify/resolve-by-handles.ts— join a Shopify product (batched handle lookup) with DB patch metadata into the view-model both grids render. -
backend/src/store-my-patches/store-my-patches.{service,controller}.ts— the "what has this scout collected" endpoint. -
backend/src/store-collections/store-collections.{service,controller}.ts—getCollections, the source for "Your sets". -
backend/src/store-orders/store-orders.controller.ts— order history endpoint (unreachable from/app's UI today). -
backend/src/hat-favorites/hat-favorites.{controller,service}.ts— Hat Studio backend, no current UI caller. -
backend/src/scripts/create-trail-hats.ts:249,create-blank-trail-hats.ts:119— every Trail Hat product is created withtags: ['hat', 'internal'], relevant to the Promo tag discrepancy below. -
mobile/app/store-webview.tsx,mobile/src/config/store.ts— the WebView screen andbuildStorePathUrl/storeHref, shared withcommerce-and-store.md. -
mobile/app/rewards.tsx,store/app/app/rewards/page.tsx— the Rewards entry point and page; see rewards.md for the full feature.
The cart rewards carousel
store/components/store/CartDrawer.tsx— the shared cart drawer (mounted from bothAppStoreHeader.tsxhere and the public shopfront'sHeader.tsx); owns the reward fetch, the pin/choose wiring, the discount cookie effect, the auth-change reset, and the (gated) suggestion-candidates query.store/components/store/CartRewards.tsx— the carousel + "one more to unlock" row, pure presentation over the propsCartDrawer.tsxcomputes.store/lib/store/rewards.ts—Reward,partitionRewards,usableRewards(the cart-eligible subset: issued, coded, not expired, known value/scope),runCartCookieAction(the DOM executor for the discount cookie and the ad-code stash,AD_CODE_STASH_KEY) with itsapplyRewardToCart/clearRewardFromCartwrappers,pinCartReward/readPinnedCartReward/unpinCartReward(the session pin both the drawer and the Rewards page write to),CART_REWARD_PIN_KEY.store/lib/store/reward-savings.ts—lineQualifies(scope-match, mirrors the Shopify collection rules inbackend/src/rewards/reward-kinds.tsand the collection-creation scripts — the two must move together),rewardFit(does a reward apply to this cart, and for how much),chooseReward(best-saving/earliest-expiry/id tiebreak, honoring the pin).store/lib/store/reward-suggestions.ts—oneMorePrompt(the "add one more" nudge for a minimum- or product-scoped reward one item short),suggestionHandles(which product handles to fetch, shared-collection patches first,MAX_SUGGESTIONS = 10).store/lib/store/reward-card-state.ts—rewardCardState, one shared decision point for a carousel card's selectability and detail text.store/lib/store/cart-cookie.ts— the pure rules for the one discount cookie:appliedReward(a pin wins; otherwise the best reward only if it beats the ad offer),cartCookieAction(apply/clear/none),nextCookieState(stash an ad code a reward displaces, restore it on clear, never stash an own code),adCodeInPlay.store/lib/store/cart-rewards-auth.ts—resetOnAuthChangeand the two query keys it drops on a sign-out or sign-in.store/lib/store/cart-discount.ts—cartDiscountRow, the one line the cart footer shows: the applied reward's saving, else the bigger of an ad-link launch discount and the automatic quantity break, else nothing.store/lib/store/shelf-offer.ts— the two quantity breaks: what the shelves advertise (offerFor/offerLine) and, in the cart,offerFit,bestShelfOffer,shelfOfferNudge,nudgeLine,offerDiscountLabel.store/lib/store/offer-nudge.ts—offerNudgeCards, the "Add one more tee" row's cards (add/choosein the cart /linkfor a locked hat) andchoiceLabel.store/components/store/CartQuickChooser.tsx— the in-cart colour/size chooser achoosecard opens.store/lib/shopify/offer-candidates.ts—getOfferShelf, the offer collection's products for that row, with their variants and patch-hat gate.store/components/store/CartNudgeRow.tsx— the headline + swipeable card row shared by the reward's "one more to unlock" and the quantity break's nudge.store/lib/store/cart-rewards-payload.ts—cartRewardsPayload, joiningGET /api/rewardsrows withGET /api/store/my-patches'scollectionsfield into what the carousel needs, server-side.store/lib/store/use-cart-rewards.ts—useCartRewards(), theretry: falsequery wrappingGET /api/cart-rewards.store/app/api/cart-rewards/route.ts— the Next.js route: 401-and-empty signed out, empty on any backend failure, never throws into the cart.store/lib/shopify/reward-candidates.ts—getRewardCandidates, the Storefront API lookup (up to 30 handles) backing the suggestion tiles.backend/src/store-my-patches/store-my-patches.service.ts—MyPatchDtonow carriescollections: {id, name}[]per patch, the dataoneMorePrompt's "same collection first" ordering reads.
Configuration and flags
- No feature flag gates the
/apptree itself or any of its five sections — it is reachable to any signed-in user, always. The only gate is authentication (page.tsx:64-68). PATCH_PRICE_USD = 8,COLLECTION_PATCH_PRICE_USD = 5(store/lib/collection-pricing.ts:11,14) — hardcoded numeric constants, not env vars, not DB config, not read fromCollection.priceUsd(which exists in the schema but is not consulted by any code path in this surface).NEARLY_WITHIN = 2(SetShelf.tsx:24) — the "how many missing still counts as nearly there" cutoff; independently re-passed aswithin: 2inStoreProgressHeader.tsx. Both derive from the same sharedearned-sets.tsfunctions, so the two are guaranteed to agree with each other on any given page load, but the constant itself is duplicated as two separate literal2s across the two call sites rather than a single shared export.FAN_LIMIT = 4(SetShelf.tsx) — thumbnails that overlap in the stack before the rest become a+n; max thumbnails shown on a set card before the rest is summarized by dashed placeholders.SWAG_PRODUCT_TYPES = ['T-Shirt', 'Hats'](swag.ts:20) — the only two Shopifyproduct_typevalues that count as swag; changing what Printify assigns a product would silently move it on/off this shelf.storefront_curated_mvp,storefront_hats_enabled,limited_inventory_mode— all real flags documented incommerce-and-store.md, but none of them affect this surface:/app's collections call has no curated filter, and the Swag shelf's Hats inclusion is independent of the public catalog's hats-category gate (store/lib/hats-visible.tsonly affects the public browsable catalog, not the tag/type-driven Swag query here).- No feature flag gates the cart rewards carousel. It is reachable to any
signed-in shopper the moment they hold a usable reward; a signed-out
shopper or one with none simply sees no carousel.
MAX_SUGGESTIONS = 10(reward-suggestions.ts) caps the "one more to unlock" row; the Storefront lookup backing it caps at 30 handles (reward-candidates.ts).
Edge cases and known limits
- The Promo tag filter and the Trail Hat's
internaltag appear to conflict.buildPromoQuery()excludestag:internal(store/lib/store/promo.ts:16), but both hat-creation scripts always tag every Trail Hat['hat', 'internal'](backend/src/scripts/create-trail-hats.ts:249,create-blank-trail-hats.ts:119). For a Trail Hat SKU to appear on the Promo shelf (as the task's screenshot shows, "Trail Hat — Saint Louis Zoo $15"), that specific product'sinternaltag would need to have been removed by hand in Shopify admin after creation — this doc could not verify that without querying live Shopify data, which was out of scope. Flagged as an open question, not asserted as a bug. - PATCHES count can undercount total collected patches. It's a count of
distinct product handles, not
UserPatchrows — a patch collected but never given a Shopify product, or duplicate collection events on the same patch, don't add to the number (owned-handles.ts:23-33). - Header price labels are literal strings, not derived from the pricing
constants.
"tap to add · $8"inpage.tsx:137is a hand-typed literal, not read fromPATCH_PRICE_USD— it happens to match today but isn't wired to it, and could go stale if the constant or Shopify's real price changes. - A zero-patch scout sees: PATCHES 0 / SETS STARTED 0 / NEARLY DONE 0,
no "Your sets" shelf at all (
SetShelfreturnsnullwhen bothreadyandnearlyare empty,SetShelf.tsx:153), Promo and Swag shelves unchanged (they don't depend on ownership), and a plain empty-state message in place of the patch grid: "Collect patches out in the world and they'll show up here, ready to order as embroidered originals." (page.tsx:141-144). - Ownership hydration race:
StoreProgressHeaderandSetShelfboth readuseOwnedHandles()client-side; before that hook's first fetch resolves,handlesisnull, and bothcompletedSets/nearlySetsreturn[]rather than a false-positive flash (earned-sets.ts:96,:150) — so on a slow connection the whole "Your sets" shelf can briefly disappear/reappear after the page's static HTML has already painted. HatFavoriteand its endpoints are live but orphaned. Nothing in the current codebase calls them; they exist only because the backend module was never deleted when Hat Studio was removed (commit55cf2123, percommerce-and-store.md).- The
patches-and-patch-hatsShopify collection exists in production (created 2026-09-22, see rewards.md's Edge cases), sopct_10_off/pct_20_offmint for real. The cart's ownrewardFit()estimate never depended on it: it reads the collection RULE (lineQualifies) rather than querying Shopify for product membership. - A carousel card's "Saves $X" is an ESTIMATE, not what checkout will
charge.
rewardFit()computes savings client-side from the cart's own cached line prices; it marks the figure(about)whenever a qualifying line is on sale (Shopify may round a percentage differently against a discounted price), but even an un-marked figure is this cart's own calculation, not a round-trip to Shopify. The discount actually applied is whatever the minted code's own rules produce at checkout.
What this feature does NOT do
- The close bridge carries exactly ONE message, in ONE direction. The store can ask the app to close, and nothing else. No cart contents, no navigation, no analytics, no auth — and the app never sends the page anything except the one-time capability marker. If you need the store to tell the app something, that is a new message and a new contract on both sides.
- The store cannot close the app WebView by itself. It can only ask. The
app raises the
Alertand dismisses only if the scout picks "Leave", so a page — including a hostile or compromised one — can at worst show the confirm dialog. That is the whole blast radius of the bridge. - The app does NOT rely on the page to provide an exit. Anything outside
the
/apptree — Shopify checkout above all — gets a native close drawn by the app itself. The bridge is how the store's own close works, not the only way out. - A browser visitor to
/appnever sees a close button. Without the injected marker,CloseStoreButtonandAppStoreBackLinkrendernulland the header keeps its ordinary back-chevron. There is no query-param or user-agent override, deliberately. - An OLD app build shows no close button either, and that is the point — it still has its own native header. The bridge is versioned by the presence of the marker, not by an app-version check.
- The disabled back-swipe is NOT covered by any automated test.
gestureEnabled: falseis consumed by the native navigator; nothing in Jest can observe it. Android'shardwareBackPresspath is tested, and runs the sameconfirmClose, but the iOS swipe itself is verified only by hand. - The page does not draw under the status bar. The WebView starts below
the top safe-area inset; the storefront ships no
viewport-fit=coverand noenv(safe-area-inset-*)CSS, because that CSS would also reach every browser visitor of/app. - Does not apply any bundle/set discount at cart/checkout time as a
discount code or line-item override. The $5-a-patch price is a real
Shopify variant price on the collection's OWN product (see "How it works"
above) — buying that one product does check out at that price once it has
been repriced. But adding member patches individually from
PatchAddGridstill always costs each patch's own $8 variant price; there is no mechanism anywhere that discounts a per-patch cart line for owning the rest of a set. - Does not show a set total that always matches checkout. The $5/patch
set price IS live (the heading here used to say otherwise): the bundle
product exists, the gate is live, and
backend/src/scripts/sync-collection-bundle-pricing.tswas applied to production on 2026-09-02 — so tapping "Order the set" now checks out atpatchCount × $5, not the old flat $8. The remaining discrepancy runs the other way: the shelf's displayed total is computed from the staleCollection.patchCountcolumn, so eight collections show a number one or two patches too high relative to what checkout charges. - Does not compute "N unlocked" per item for Promo or Swag. Both counts are just the length of the Shopify query result — every returned item is shown to every signed-in scout the same way. Nothing on these two shelves is actually gated by what the scout has earned; that gate applies only to the patch grid below them.
- Does not let a "nearly there" set be pre-purchased or reserved. A set
missing even one sellable patch offers no buy affordance anywhere on this
surface — only informational progress text and a locked/dashed thumbnail;
canBuySetrequires exact completion (or over-completion), never "almost." - Does not offer a set at all if it has zero sellable members. A
collection whose every member lacks a Shopify product can never appear as
"ready" or be bought as a set —
completedSets/canBuySetboth requiresellableCount ≥ 1before evaluating completion. - Does not read
Collection.priceUsdfor the set price shown here; that DB field is used elsewhere (store-exclusive bundles) but not by this shelf. - Does not surface order history, account settings, or any navigation out
to the public shopfront from inside
/app—GET /api/store/ordersworks, but nothing understore/app/app/**links to it (the order-history page lives on the walled public shopfront); the header offers only back and cart. - Does not gate the Swag/Promo shelves, or curate "Your sets," behind any
feature flag —
storefront_hats_enabled,storefront_curated_mvp, andlimited_inventory_modeall exist but none of them touch this surface. - The cart never applies more than one reward code, and a reward code never
stacks with another discount. Every mint carries
combinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }(rewards.md); the cart's ownchooseReward()also picks exactly one reward regardless. Free shipping is the one thing that still applies alongside it. - Never gives two discounts on one order — not two quantity breaks, not a quantity break plus a code. A cart with 2 tees and 2 National Park hats gets the bigger of the two breaks, not both.
- The "one more to unlock" row never suggests a locked patch. Every
candidate comes from
GET /api/store/my-patches's own list — patches this shopper has actually unlocked — filtered again againstunlockedHandlesbefore it can appear (oneMorePrompt,reward-suggestions.ts). The store sells nothing a scout hasn't earned, and this row does not create an exception. - A percentage reward's estimate never counts a set bundle or apparel
line.
pct_10_off/pct_20_offare scoped topatches-and-patch-hats;lineQualifies()matches onlyIron-on Patchproduct type or thepatch hattag, so a completed-set bundle product, a tee, or a Snapback in the same cart contributes nothing to either percentage's saving. - The invited friend gets no reward from any of this. Only the referrer who reaches the 10-friend rung is ever granted the free patch; see referral-and-invite.md.
- Is not a different checkout mechanism from the public shopfront — same
cartCreateShopify mutation, same hosted checkout redirect, no in-app purchase, no subscription, nothing beyond whatcommerce-and-store.mdalready documents for checkout generally.
Tests that cover it
-
store/lib/store/offer-nudge.test.ts— the quantity-break row's cards: a sized tee becomes an in-cartchoosecard (not a link), the NP hats read "Pick a color" not "Pick a size", a two-axis tee names both, a one-variant product adds in a tap and is dropped when sold out, an unearned patch hat (and a malformedpatch:gate, even for an owner) stays alink, an earned one chooses in the cart, and in/appchoosers show while locked cards drop. -
store/lib/store/native-host.test.ts— the close bridge's gate: a plain browser window,ReactNativeWebViewpresent without the marker (the old-build case, which must NOT count as a host),canClosefalse/missing/string-'true'/null, the exact payload shape, and posting with and without a bridge. PlusheaderControls— browser arrangement, in-app arrangement, the root case, and the "exactly one back affordance" invariant. -
mobile/src/config/__tests__/storeBridge.test.ts— the marker script is real executable JS that assigns{ canClose: true }and firesscout:host-ready; the message parser rejects malformed JSON, arrays, bare strings, a wrongtypeand non-strings without throwing; andisInAppStoreUrltrusts the/apptree, refuses Shopify checkout, refuses our own origin outside/app, is not fooled by/apparelor a foreign host serving/app, and treats nothing-loaded-yet as untrusted. -
mobile/screen-tests/store-webview.test.tsx— the screen: the capability marker is handed to the page, no native chrome is drawn over a store page that can close itself, a native close IS drawn over Shopify checkout and from the very first frame, a close message raises the confirm and only "Leave" dismisses (with "Stay" as its paired falsification), a foreign message is ignored, Android hardware back raises the same prompt and swallows the press, an HTTP error raises the failure screen, and that screen exits immediately without prompting. -
store/lib/store/reward-savings.test.ts—lineQualifies/rewardFit: the percentage scope takes patches and patch hats and never a set bundle, tee, or the Scout patch; a percentage saves its share of qualifying lines only and doesn't apply with nothing qualifying; buy-one-get-one needs two patches and reports how far short one is; a patch hat doesn't count toward it; the Scout patch reward applies only with the Scout patch itself in cart; a sale-line saving is flagged an estimate.chooseReward: the best reward is the biggest saving, a tie goes to whichever expires first, a pinned reward wins while it still applies and is dropped the moment it stops, and no reward is chosen when none applies. -
store/lib/store/reward-suggestions.test.ts—oneMorePrompt: one patch in cart asks for one more from the same collection first; no prompt once buy-one-get-one already applies; never suggests a patch the user has not unlocked (falsifying test by name); never suggests what's already in cart, out of stock, or not a patch; percentage rewards never prompt (no minimum to be short of); the Scout patch reward suggests the Scout patch itself; no prompt when nothing unlocked can be suggested; generic message with no shared collection.suggestionHandles: same-collection-first ordering, capped, and a 35+-unlocked-patches case proving a product reward's handle survives the cap rather than being sliced off. -
store/lib/store/reward-card-state.test.ts— an empty cart shows the description and is unselectable; an applying reward is selectable and shows the saving; an estimated saving says so; a product-scope reward that doesn't apply prompts for the Scout patch; a below-minimum reward prompts for one more patch; a reward with no eligible items at all falls back to generic collection copy. -
store/lib/store/cart-cookie.test.ts— a chosen reward with a code applies it; nothing chosen but the user holds reward codes clears exactly those codes; no rewards at all does nothing (never clears — the still-loading/ signed-out case, so an ad-link code can't be wiped before rewards have loaded); a pinned reward that isn't the biggest saving still wins the cookie; an empty cart with a pin clears, then a qualifying line re-applies the same pinned reward; an ad code a reward displaces is stashed and restored on clear; own codes are never stashed; a bigger (or equal) ad offer beats an auto-picked reward and keeps its row; a bigger reward beats a smaller ad offer; a pinned smaller reward still applies over a bigger ad offer. -
store/lib/store/cart-rewards-auth.test.ts— an auth change takes the previous user's code out of the cookie, writes a stashed ad code back, leaves an ad code alone, drops the pin and both reward queries. -
store/lib/store/rewards.test.ts(executor) — against a fake cookie jar and sessionStorage: applying over an ad code stashes it and clearing writes it back; one own code over another stashes nothing;unpinCartRewardforgets the pin. -
store/lib/store/cart-discount.test.ts— a chosen reward is the discount row; with no reward an ad-link offer is; an estimated saving says so; no reward and no offer means no discount row (the retired multi-buy break has no path back into this function). -
store/lib/store/cart-rewards-payload.test.ts— keeps usable rewards and only patches that have a product handle; an empty account yields empty lists, not an error. -
store/lib/store/earned-sets.test.ts—completedSets/nearlySets: exact-completion, one-away, two-away, threshold boundary, ordering, duplicate-handle de-dup, sellable-vs-raw-patchCount sizing, null-ownership behavior. The most thorough test file touching this surface. -
store/lib/collection-pricing.test.ts— bundle math for a 12-patch and a 10-patch collection, and the zero/empty-collection floor. -
store/lib/store/set-gate.test.ts—canBuySet/evaluateSetCompletion: completed set, one-short, owns-nothing,publicPurchasebypass, zero-patch collection, over-ownership. Mirrored bybackend/src/store-collections/set-purchase-gate.spec.ts(byte-for-byte the same six cases against the backend copy of the predicate). -
store/lib/store/set-shelf-buy-state.test.ts—setBuyState: buyable at the discounted price, locked with the correct remaining count at one and two patches short,publicPurchasealways buyable. -
backend/src/scripts/lib/bundle-pricing.spec.ts—bundlePriceUsd(patchCount × $5, refuses ≤0),bundlePriceUpdates(diff/no-op/missing-product skip), and the drift guard that readsstore/lib/collection-pricing.tsas text to assert the two projects' price constants have not diverged. -
backend/src/scripts/lib/catalog-audit.spec.ts—classifyProduct/selectForDeletion: protects swag andscout-patch, deletes only admin-only collection products, never deletes an orphan or a live set. -
store/lib/store/patch-tile-state.test.ts— addable/locked/sold-out state machine used byPatchAddGridon both the store home and collection detail pages. -
store/lib/store/owned-handles.test.ts—uniqueProductHandles(dedup, order preservation, handle-less patches dropped) — the PATCHES stat's source logic. -
store/lib/store/swag.test.ts,store/lib/store/promo.test.ts— the Shopify query strings for both shelves, including the-tag:internalexclusion and the product-type parenthesization. -
backend/src/store-my-patches/store-my-patches.service.spec.ts— the/api/store/my-patchesservice, "returns the user's collected patches flattened with display fields, newest first." -
backend/src/store-collections/store-collections.service.spec.tsand__tests__/store-collections.service.requires-unlock.spec.ts— collection listing and the unrelated per-patch unlock gate (shared code, covered fully incommerce-and-store.md). -
mobile/screen-tests/store-webview.test.tsx— WebView URL construction (already cited incommerce-and-store.md; covers that entry points default to/app, not a product page).
The pure logic behind these is now unit-tested — store/lib/store/store-progress.test.ts
(caption grammar at n=1, the empty-state copy, and a bar that cannot divide by
zero or overflow its track) and store/lib/store/set-shelf.test.ts (orderable
sets first, stable tie-breaking, and a +n that counts collected patches only),
plus store/lib/store/shelf-order.test.ts (a weak average never gets promoted,
the 4.0 boundary, and unreviewed products holding their original order).
The components themselves are still unexercised: no test was found that renders SetShelf.tsx,
SwagShelf.tsx, PatchAddGrid.tsx, or store/app/app/page.tsx themselves
as components/pages — only the pure logic modules underneath them
(earned-sets, collection-pricing, patch-tile-state, owned-handles,
swag, promo) have direct tests. This doc's account of what renders where
comes from reading the components, not from a passing UI test asserting the
same layout.
Open questions
- How the Trail Hat shown in the task's screenshot ("Trail Hat — Saint
Louis Zoo $15") reconciles with
-tag:internalexcluding every Trail Hat product by default (create-trail-hats.ts:249). Either that specific product had itsinternaltag manually removed in Shopify admin, or the screenshot reflects a moment before/after a tagging change this doc cannot see without a live Shopify query (explicitly out of scope for this audit). - Whether
NEARLY_WITHIN/the "within 2" cutoff is meant to be a single shared constant rather than the literal2appearing independently in bothSetShelf.tsxandpage.tsx's call intoStoreProgressHeader(which itself hardcodeswithin: 2) — no ticket or comment stating whether the duplication is intentional. The two currently agree because both trace back to the sameearned-sets.tsfunctions, but nothing enforces that a future change to one would update the other. - Whether
Collection.priceUsd, present in the schema, was ever meant to drive this shelf's price instead of the hardcoded $8/$5 constants — no comment or ticket found either way; today the two are entirely disconnected (priceUsdis read elsewhere, for store-exclusive bundles). - Whether the
hat-favoritesbackend module is scheduled for removal along with the rest of the deleted Hat Studio code, or is being kept around for some other purpose — no ticket found. - The live Shopify price of a standalone patch, which this doc's $8
figure assumes (matching
commerce-and-store.md's own unresolved question) — not independently re-verified against Shopify here either, per the no-Shopify-API-calls constraint on this audit.