Verified against the code on branch feat/patch-shop, 2026-10-11. Citations are path:line in that tree. Re-verified on develop 2026-10-29 for the live flag, the hat buy-now path (5706da7c) and the release that carries the Buy button; registry line numbers updated.
Summary
Once a scout has collected a patch, its detail screen shows a Shop tab (a cart icon). Tapping it opens the in-app store, full-screen, on a per-patch page, /app/patch/<handle>, that lists the patch itself and every other product tagged to it in Shopify. Once the tagging script has been applied that means the national-park leather beanies and truckers and the Route 66 tees (animal hats are deliberately not tagged); until then it is the patch plus any patch:-tagged Trail Hat. A product joins a patch by being tagged in Shopify; there is no backend table and no deploy to link a new one.
Status
- Paused store (in store builds since v1.1.117, and showing in production today). With
buy_patchoff, a build carrying this change keeps the Shop tab, the Buy button and the Purchase Patch row, and they open the "would you buy this?" sheet instead of the store (patchPurchaseRoute,mobile/src/config/store.ts; seestore-interest.md). Older builds hide them when the flag is off. Everything below describes the flag ON. - Off in production today. The Shop tab opens the store when
buy_patchis on,defaultEnabled: falsein both registries (mobile/src/config/feature-flags.ts:105,backend/src/admin/feature-flag-definitions.ts:120), and it readsfalsein production, verified live on 2026-11-06 viaGET https://scout-patches.com/api/feature-flags("buy_patch":false). History:trueon 2026-09-13,falseon 2026-10-11,trueon 2026-10-15 and 2026-10-29. Re-check the live flag before saying either way. - The store page itself is NOT flagged.
/app/patch/<handle>exists in the storefront code and answers any request that carries a valid handle. It does no sign-in redirect (store/app/app/layout.tsx:21-30has no auth; the page comment says so,store/app/app/patch/[handle]/page.tsx:36-40). Earn-to-buy still applies: a visitor who has not collected an earn-to-buy patch sees the locked "Earned, not bought" callout, not the products. - Backend change not deployed. The catalog endpoint now returns the patch's
name/city/state(backend/src/store-collections/store-collections.service.ts:526-528). Until the backend is deployed, the page falls back to the Shopify product title and shows no place line (store/lib/store-catalog.ts:40-42,store/app/app/patch/[handle]/page.tsx:62). - The tagging script has NOT been run with
--apply. As of 2026-10-11 nofor-patch:tag has been written to the production Shopify store, so every patch's page would show only the patch itself (plus anypatch:-tagged Trail Hat) today. Only a dry run exists (2026-10-11, see Open questions).
Before turning buy_patch on
- Deploy the storefront with
/app/patch/[handle]FIRST. Otherwise every Shop tap and Purchase Patch row lands on the store's 404 inside the webview. - Deploy the backend for the place line (optional; the page falls back to the Shopify title with no place).
- Old app builds. Builds released BEFORE this change still contain the celebration screen's Purchase button gated on
buy_patch. Flipping the flag brings that button back for anyone on an old build; decide whether to wait for adoption of the new build first. - Seven-tab bar. A battlefield patch with My Visit photos and Shop has seven tabs (pinned by
useVisibleTabs.test.ts). Verified overflowing on 2026-10-11 at 375pt (iPhone 13 mini: Shop landed 18pt past the pill, cart clipped); fixed by showing the selected tab as an icon only when the bar has seven tabs (FloatingTabBar.tsx:43,COMPACT_AT; applied atFloatingTabBar.tsx:165). Six tabs or fewer are unchanged. Pinned bymobile/screen-tests/floating-tab-bar.test.tsx. - Tagging script. Run
shopify:tag-patch-relatedwith--apply(needs Alan's OK) so the More shelf has the park hats and tees. The 2026-10-11 dry run plans 33 adds (12 beanies, 12 truckers, 9 tees); hand-tagpetrified-forest-route-66-teein the same sitting.
User-facing surfaces
- Shop tab on both patch detail screens: the current one (
PatchDetailScreenV3,mobile/src/components/patch-detail-v3/PatchDetailScreenV3.tsx:68,:84-93) and the previous one (mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:51,:54-62). Both read the tab list fromuseVisibleTabs. - Cart icon in the floating tab bar:
shop: ShoppingCartIcon(mobile/src/components/patch-detail-v2/FloatingTabBar.tsx:34), testIDpatch-detail-tab-shop(:98). - "Purchase Patch" row in the patch action sheet (the three-dot menu): now opens the same per-patch page instead of the store home (
mobile/src/components/patches-v2/PatchActionSheet.tsx:64-70,mobile/src/scout/patch/PatchActionSheet.tsx:85-91). /app/patch/<handle>in the storefront (store/app/app/patch/[handle]/page.tsx:41).- Buy now on the product page (
/app/products/<handle>?buynow=1, store side shipped 2026-10-25): for a patch the viewer owns, the gold CTA reads "Purchase for $8.00" (the live variant price) instead of Add to cart, and one tap goes straight to a Shopify checkout holding that patch alone (store/components/store/BuyNowButton.tsx, chosen instore/components/store/PatchBuyArea.tsx). It is opened by the app's Buy button (below). Design:mobile/research/buy-now-lab.html(variant B picked 2026-10-25). Hats too, since 2026-10-27 (5706da7c): the same?buynow=1on the public merch page/products/<handle>puts "Purchase for $X" on ashop-tagged hat, read client-side because that page is ISR-cached (store/components/store/MerchBuyArea.tsx:38-64,:120-135,:177-182); apatch:-gated hat the viewer has not earned still shows the locked callout, and a hat with a real Size choice never guesses one. That is the shopfront's path for paid-social ads, not something the app links to — detail incommerce-and-store.md§ Buy now. - Buy button in the current patch detail hero, between Navigate and the compass (
mobile/src/components/patch-detail-v3/PhotoHeader.tsx, testIDpatch-detail-buy):HeroSquareButtonwith a bag icon and the word "Buy" (label,mobile/src/components/home-v3/Hero.tsx). Same gate as the Shop tab (shouldOfferPurchase), only onPatchDetailScreenV3, never the previous screen. Tap sendspurchase_click(fromScreen: 'patch-detail-buy') and pushes/store-webviewwithpatchBuyNowPath(handle)=/app/products/<handle>?buynow=1(mobile/src/config/store.ts). Built 2026-10-25 (b3a44f35); shipped in app 1.1.113 (b3a44f35is an ancestor of the 1.1.113 version bumpd36a9051, 2026-10-26). Whether a given device has that build is adoption, not code. - Deep link:
scout://store-webview?path=/app/patch/<handle>. No new app route;store-webviewalready takespath(mobile/app/store-webview.tsx:42,:73), sodeepLinkRoutes.tsis untouched. The drawer's Store row still opens the whole shop (/app).
How it works
- Gate.
PatchModalScreencomputesshopHandlewithshouldOfferPurchase: the patch is collected AND has aproductHandleAND has patch art (patchUrl; no art means no store item, so no Shop tab, Buy button or Purchase Patch row, fixed 2026-10-26). Since 2026-11-03buy_patchis not part of the gate; it picks the destination, store or questionnaire (patchPurchaseRoute) (mobile/src/screens/PatchModalScreen.tsx:190-210;mobile/src/domain/purchaseGate.ts:12-19).openShopis undefined otherwise (:356), and the screens show the Shop tab exactly whenonShopis set (PatchDetailScreenV3.tsx:68). - Tab list.
buildVisibleTabsappends{ id: 'shop', label: 'Shop' }last whenshopis true (mobile/src/components/patch-detail-v2/useVisibleTabs.ts:41-60). - Tap. Both screens intercept
'shop'in their tab-change handler: they callonShop()and return withoutsetActive, so the active tab does not change and closing the store lands the scout where they were (PatchDetailScreenV3.tsx:84-93,PatchDetailScreen.tsx:54-62).openShoppushespatchPurchaseRoute(...):/store-webviewwithpath = patchShopPath(shopHandle)whenbuy_patchis on,/patch-interest/[id]when it is off (PatchModalScreen.tsx:202-205). - Path.
patchShopPath(handle)isstoreHref('/patch/<encoded handle>'), i.e./app/patch/<handle>(mobile/src/config/store.ts:148-150). It is the one builder, shared by the tab and both action sheets. - WebView.
store-webviewruns the existing silent-login hop through/auth/nativeand lands on the path (mobile/app/store-webview.tsx:73; seemobile-store.md). - Page. The server page rejects a handle outside
^[a-z0-9][a-z0-9-]*$with a 404 before any fetch (store/lib/store/patch-shop.ts:103-105,page.tsx:44), then fetches in parallel the patch product (getApparelProduct), the backend product context, the scout's owned handles, ratings and app links (page.tsx:46-52).patchShopOutcomedecides 404 vs render (patch-shop.ts:135-138,page.tsx:54). The patch'scoll:<c>tags becomecollHandles(patch-shop.ts:26-33), and one Storefront search runs (getLinkedProducts(buildPatchShopQuery(...)),page.tsx:56-57). - Assemble.
assemblePatchShopremoves the patch itself, de-duplicates by handle, drops anything notavailableForSaleand anything with no link tag, and orders bylinkRank:patch:H(0),for-patch:H(1),for-coll:<c>(2) (patch-shop.ts:51-91). The sort is stable, so within a rank the order is what Shopify returned. - Render.
PatchShopView(store/components/store/PatchShopView.tsx:37) shows a "Patch" kicker, the name and the place (:40-46); the locked callout when locked (:48-51); otherwise "Your patch" with the patch as oneGalleryPiecefield card (:54-63) and "More for <name>" with aSwagShelf(:65-70). It adds no card, button or shelf of its own.
Buy now (?buynow=1)
- Param.
app/app/products/[handle]/page.tsxreadssearchParamsand passesbuyNow = isBuyNow(...)(exactlybuynow=1; a repeated param reads its first value) toPatchDetail, which hands it andproduct.variants[0].pricetoPatchBuyArea(store/lib/store/buy-now.ts). - Gates, in order. Locked (not owned) → the
LockedCallout, param ignored. Sold out →AddToCartButton's disabled "Sold out". Otherwise, withbuyNow→BuyNowButton; without →AddToCartButtonas before. In this/apptree onlyPatchBuyAreareads it, so sets ignore the param. Since5706da7cMerchBuyAreareads it as well (client-side,isBuyNowSearch), which is how a hat's public page/products/<handle>?buynow=1honours it — and apatch:-gated hat still locks (MerchBuyArea.tsx:104-122). - Tap.
buyNowOnce(a synchronous latch: a double tap's second click lands before React re-renders the button disabled, which built two Shopify carts in Chrome on 2026-10-25) firestrackInitiateCheckout(numItems: 1,productHandle), calls the existingcreateCheckoutSessionwith one line (so the discount cookie,scout_sourceattribution and the zeroed-line sold-out check apply), and only once Shopify returns a checkout empties the Scout cart and setswindow.location.href(runBuyNow). A failure re-enables the button, showsCouldn't start checkout: <message>, and leaves the cart untouched. - Not consumed. The param stays in the URL, so coming back from checkout still shows Purchase.
- Vouchers apply (2026-11-06).
BuyNowButtonrunsuseCartPricingon its one line, so it picks a reward by the cart's own rules (appliedReward). A qualifying voucher makes the label the price left to pay (buyNowPrice, "Purchase for $0.00"), and the tap writes its code to thescout_discountcookie before the checkout is built (runBuyNow'sapplyDiscount). Before this, buy now had no cart to write the cookie, so a voucher holder reached Shopify at full price. The button stays disabled until rewards and the live line have answered. No reward (signed out, used, expired, or for another product) leaves the price and any ad code as they were. This means buy now spends a qualifying reward automatically, as the cart already does.
Data model
No database table. The product-to-patch link is Shopify tags, the contract (spec, 2026-10-11):
| Tag | Meaning | Locks the product? |
|---|---|---|
patch:<handle> |
existing: the earn-to-buy gate for a patch hat. Also shown on that patch's page | yes, to owners only |
for-patch:<handle> |
shows on that one patch's page | no |
for-coll:<collection handle> |
shows on the page of every patch whose product carries coll:<collection handle> |
no |
for-patch:/for-coll: never trip the buy gate (tagBuyGate reads patch: by prefix; pinned at store/lib/store/patch-hat.test.ts:274-281) and never render as a chip (visibleTags drops any tag containing :; pinned at store/lib/product-tags.test.ts:25). Prefix constants: store/lib/store/patch-shop.ts:19-21.
API surface
GET /api/store/catalog/:handle: thepatchbranch of the product context now carriesname,city,state(backend/src/store-collections/store-collections.service.ts:95-102, selected at:456-461, returned at:524-532). Additive; the storefront type marks them optional for an older backend (store/lib/store-catalog.ts:40-42). Not yet deployed as of 2026-10-11.GET /api/store/my-patches(JWT): the collected patches' product handles, read bygetOwnedPatchHandles(store/lib/store/user-state.server.ts:67-86). Fails closed: any error yields an empty set, not "owns everything".- Shopify Storefront API (read-only from the page):
GET_APPAREL_PRODUCT(store/lib/shopify/queries.ts:292) and the tag search above.
Key files
Mobile
mobile/src/config/store.ts:148:patchShopPath.mobile/src/components/patch-detail-v2/useVisibleTabs.ts:6,41,62:TabIdgains'shop';buildVisibleTabs,useVisibleTabs.mobile/src/components/patch-detail-v2/FloatingTabBar.tsx:34: the cart icon.mobile/src/screens/PatchModalScreen.tsx:190-204,356: the gate andopenShop.mobile/src/components/patch-detail-v3/PatchDetailScreenV3.tsx:84-93,mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:54-62: the 'shop' intercept.mobile/src/components/patches-v2/PatchActionSheet.tsx:64,mobile/src/scout/patch/PatchActionSheet.tsx:85: "Purchase Patch".mobile/src/domain/purchaseGate.ts:11:shouldOfferPurchase.
Backend
backend/src/store-collections/store-collections.service.ts:524: the patch context withname/city/state.backend/src/scripts/tag-patch-related-products.ts: the tagging script (dry run by default);package.jsonscriptshopify:tag-patch-related.backend/src/scripts/lib/patch-related-tags.ts:45:planRelatedTags, the pure planner.backend/src/scripts/lib/shopify-tags.ts:2: sharedTAGS_ADD.backend/src/scripts/lib/endpoint-banner.ts:31-47: the Shopify-only banner mode.
Store
store/lib/store/patch-shop.ts: tag prefixes,collectionHandlesOf,buildPatchShopQuery,linkRank,assemblePatchShop,itemCount,isPatchHandle,ownsPatch,patchShopAccess,placeLine,patchShopOutcome.store/components/store/PatchShopView.tsx: the page body.store/components/store/SectionHead.tsx: extracted fromstore/app/app/page.tsxso both pages share it.store/app/app/patch/[handle]/page.tsx: the route.store/lib/store/buy-now.ts:BUY_NOW_PARAM,isBuyNow,buyNowLabel,buyNowPrice,runBuyNow,buyNowOnce.store/components/store/BuyNowButton.tsx: "Purchase for $X" (sharesAddToCartButton.module.css).store/app/app/products/[handle]/page.tsx: reads?buynow=1.store/test-support/server-only.mjs: lets the node test runner render a client component that imports a server action.store/lib/shopify/apparel-catalog.ts:138,153:getApparelProduct(throws on a Shopify failure) andgetLinkedProducts(never throws; cap 50).
Configuration and flags
buy_patch: its admin description now reads that it shows the patch detail Shop tab and the action-sheet Purchase Patch row, both opening the per-patch store page. Gates the Shop tab and the "Purchase Patch" sheet row (both throughshouldOfferPurchase). Defaultfalse; off in production (falseon/api/feature-flags, 2026-11-06 — see Status). Two registries (mobile/src/config/feature-flags.ts:105,backend/src/admin/feature-flag-definitions.ts:120); the DB row wins over the default. The page's route has no flag.- Tagging script.
cd backend && npm run shopify:tag-patch-relatedis a dry run that reads the live Shopify catalog and prints "To add", "Already tagged", "Skipped — not a national park" and "Unmatched". The leather hats are linked only when their base patch product carriescoll:national-parks(backend/src/scripts/lib/patch-related-tags.ts,NATIONAL_PARK_TAG); the rule is data, not a handle list. The 24 animal trucker hats fail it and are listed under Skipped, never tagged (Alan, 2026-10-11).-- --applyWRITES THE PRODUCTION SHOPIFY STORE (mjhd81-vs.myshopify.comper the banner). Run it only with Alan's explicit OK, after he has read the dry run. It only ever addsfor-patch:tags (TAGS_ADDkeeps every existing tag) and never touchespatch:,group:orshop:. Re-running is safe: already-tagged products are skipped (tag-patch-related-products.ts:85).--applyre-plans from the live catalog, so compare its "To add" list to the dry run that was approved.
Edge cases and known limits
- Only-itself patches (Alcatraz, in the render test): the page is the patch card alone and no "More for" shelf (
PatchShopView.tsx:65;store/lib/store/patch-shop-render.test.ts:75). - Archived or missing patch product with related products: the "Your patch" card is dropped but the "More for" shelf still renders (
patch-shop-render.test.ts:89). With nothing at all, one line says so: "Nothing for <name> in the store right now." (PatchShopView.tsx:60). - Deep link to a patch you have not collected: shows the locked callout, not a 404, for an earn-to-buy patch (
patch-shop.ts:119-122). Reachable only by link, because the tab requires the collect. - Signed out: no redirect.
getOwnedPatchHandlesreturnsnull, so an earn-to-buy patch shows the locked callout with the sign-in variant (PatchShopView.tsx:50). - Backend unreachable: the product context is
unavailable, which is not a 404. A real Shopify product still renders; owners still see the page when only the catalog-context call fails (the owned-handles call succeeds), but a full backend outage makesgetOwnedPatchHandlesreturn an empty set, which locks everyone, owners included (fail-closed, intended) (patch-shop.ts:129-138,patchShopAccessat:119). - Unknown handle or a collection handle: 404.
- "Unlocked" meta shows only for owners (
PatchShopView.tsx:56). orderByProofreorders inside the shelf.SwagShelfsorts by social proof (store/components/store/SwagShelf.tsx:42), so within the "More for" shelf thepatch:/for-patch:/for-coll:rank fromassemblePatchShopis not the on-screen order.- 7 tabs on a battlefield patch with My Visit photos: Overview, History, Battle, Gallery, Community, My Visit, Shop. At seven tabs the selected tab is icon-only (no label, still washed and
selectedfor accessibility), which keeps Shop on a 375pt screen (FloatingTabBar.tsx:43,:165); with six or fewer tabs the selected tab shows icon + label as before. - Shelf cap: at most 50 linked products (
apparel-catalog.ts:153); far above any patch's merch today (Arches: 3). - Cached: the Shopify reads revalidate every 300 s (
apparel-catalog.ts:142), so a new tag can take up to about five minutes to appear.
What this feature does NOT do
buy_patchdecides where it goes. On, the Shop tab opens the store; off, a build from v1.1.117 on keeps the tab and opens the "would you buy this?" sheet (store-interest.md), and older builds hide the tab. It readsfalsein production (2026-11-06, see Status); re-check before saying either way.- Buy now does not skip anything but the cart. It needs
?buynow=1(nothing on the site links to it; the app's Buy button and, for hats, an ad URL do), an owned patch — or, on the public merch page, an ungated or earnedshop-tagged hat (5706da7c, the shopfront's feature, not this one's) — and stock; it never opens a locked patch or a locked hat, never applies to sets, and does not prewarm the checkout (built on tap, roughly half a second). It empties the shopper's Scout cart once the checkout exists. - No Buy button on the previous patch detail screen (
PatchDetailScreen, V2, behindnew_patch_detail_screenoff), and not in any released build as of 2026-10-25 — it ships with the next app release. - No new product types. There are no stickers, magnets or other merch yet. This is groundwork: the page lists what already exists (the patch, Trail Hats, leather beanies and truckers, Route 66 tees). Do not advertise stickers or magnets.
- No backend patch-to-product table. The link is Shopify tags only; the app and backend do not know which products belong to a patch.
- No analytics event for the tab. Tapping Shop sends nothing. (The action sheet's "Purchase Patch" still sends
purchase_click.) - The celebration screen sells nothing. Its dormant "Purchase Patch" button (
celebration-purchase) was removed on 2026-10-11 so turningbuy_patchon cannot bring it back; a regression pin asserts it is absent (mobile/screen-tests/celebration.test.tsx:152,:386). - The drawer's Store row still opens the whole shop (
/app), not a patch page. for-coll:is tagged on no product today. The mechanism exists so whole-set merch can join later; nothing uses it, and the tagging script does not write it.- Does not make anything buyable that was locked.
for-patch:andfor-coll:never unlock or lock; apatch:-tagged hat is still owners-only, and each product card keeps its own buy gate. - Not an in-app purchase. It opens the store WebView; checkout happens on the web.
- The tagging script has not been applied. Until it is, the leather hats and Route 66 tees do not appear on any patch's page.
Tests that cover it
Mobile
mobile/src/config/__tests__/store.test.ts:patchShopPath.mobile/src/components/patch-detail-v2/__tests__/useVisibleTabs.test.ts: Shop present with the flag set and absent without; always last.mobile/screen-tests/patch-detail-new.test.tsx:387-435: the current screen's Shop tab opens the exact path, leaves the active tab alone, and is absent when each condition fails.mobile/screen-tests/patch-modal-id.test.tsx:73: the previous screen does the same.mobile/screen-tests/patch-detail-new.test.tsx"the Buy button": shown with Navigate and the compass on a collected buyable patch, opens/app/products/gateway-arch?buynow=1and sendspurchase_click; paired absent cases (flag off, not collected, no product) — falsified by ungatingopenBuyand by blanking the seed'sproductHandle.mobile/src/config/__tests__/store.test.ts:patchBuyNowPath(and handle encoding).mobile/screen-tests/scout/patch-action-sheet.test.tsx: the scout sheet's "Purchase Patch" opens the per-patch path.mobile/screen-tests/celebration.test.tsx:152,:386: no Purchase button, even withbuy_patchon and the reveal settled.
Backend
backend/src/store-collections/store-collections.service.spec.ts: the patch context carries name/city/state.backend/src/scripts/lib/patch-related-tags.spec.ts: hit, miss, de-duplicated handle, already tagged, animal hat skipped, park hat added, Route 66 tee without the route66 tag still added.backend/src/scripts/lib/endpoint-banner.spec.ts: the Shopify-only banner.
Store
store/lib/store/patch-shop.test.ts: query building, de-duplication, ordering, unavailable products, handle validation, access, 404-vs-render.store/lib/store/patch-shop-render.test.ts: loaded, only-itself, nothing to sell, locked, no place, with seeded titles and paired opposite branches.store/lib/product-tags.test.ts:25,store/lib/store/patch-hat.test.ts:274-281:for-patch:/for-coll:never gate and never show as chips.store/lib/store/shelf-order.test.ts: the shelf order the page relies on.store/lib/store/buy-now.test.ts: the param (1only, first of a repeat), the label, which voucher applies (this product's, never another's, not over a better ad offer), the price after it, the voucher going on before the checkout, create-then-clear-then-go, a failure keeps the cart, a double tap makes one checkout, a retry after failure works.store/lib/store/buy-now-render.test.ts:PatchBuyAreawith/without buy-now (paired), and buy-now ignored when sold out or locked (each falsified by reordering the gates).
Open questions
- Has
--applybeen run? Not yet, as of 2026-10-11. The dry run that day read 3,296 products and planned 57 adds (12 leather beanies, 36 leather truckers, 9 Route 66 tees), 0 already tagged, 1 unmatched. Output:.superpowers/sdd/2026-10-11-patch-shop-tab/task-9-dry-run.txt. - Petrified Forest. The one unmatched product is
petrified-forest-route-66-tee: its base handle does not match any patch product (the patch ispetrified-forest-national-park), so the script leaves it alone rather than guess. Decision (Alan, 2026-10-11): hand-tag itfor-patch:petrified-forest-national-parkwhen the--applyrun happens; the script gets no special case. - Animal leather trucker hats are excluded (decided 2026-10-11). 24 animal trucker hats exist; their bases lack
coll:national-parks, so the script reports them as skipped and never tags them. Their patch pages show no leather hat. Route 66 tees take no park rule (two stops' patches, wigwam-village-6 and totem-pole-park, lack theroute66tag). - Seven tabs on a battlefield patch with My Visit photos: not checked on a small device.