Scout — Full Product Context → feature documentation

Mobile Store — The In-App `/app` Tree

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…

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

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:

  1. Resolves the signed-in user + access token via getCurrentUser()/ getAccessToken() (cookie-based session set by the /auth/native handoff — see commerce-and-store.md); redirects to sign-in if missing.
  2. 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 every storeVisible DB collection with full patch thumbnails (getStoreCollections({ allPatches: true })).
  3. 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.
  4. Filters collections with appEarnableCollections(collections) (page.tsx:113, store/lib/store/app-earnable-collections.ts:36), which excludes both publicPurchase and adminOnly. 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 in commerce-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():

2. "Your sets" shelf — one list, nearest-to-done first, with a real buy button

SetShelf (store/components/store/SetShelf.tsx):

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 gatemeta={${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:

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)

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/):

Key files

The native close bridge

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

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