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 | That flag scopes the public CatalogPage, not /app — the in-app "Your sets" shelf reads GET /api/store/collections directly with no curated filter (store-collections.controller.ts:15-19) |
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 — free_earned_patch is no longer on the referral ladder as of the 2026-09-03 repricing (admin-grant only); minting for every kind is blocked on the missing write_discounts scope, see rewards.md's Edge cases |
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).- Entry point:
scout://store-webview(mobile deep link), opened from the drawer's Store row, the patch action sheet's "Purchase Patch" button (flagbuy_patch— hardcoded default off, but ON in production as of 2026-09-13), and the celebration screen — all detailed incommerce-and-store.md's "In-app WebView + secure session handoff" section. Every one of them routes to/app(the store home), never a specific product (mobile/app/store-webview.tsx). The route takes a singlepathparam; there is nonameparam any more, and no caller passes one. - 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:317-329, 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 an unsellable member instead — it could never reach 100% no matter what the shopper collected (confirmed against prod: Texas Hill Country, 11 members / 4 sellable, would show "collect 7 more" forever).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.
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.
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 | Public store flag reads; nothing under /app consumes any flag from this (only limited_inventory_mode/storefront_hats_enabled gate the public 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.
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).
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).
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. - Has not applied the $5/patch price to production yet. 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. - 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/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/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.