Scout — Full Product Context → feature documentation

Patch Shop

The Shop tab, Purchase Patch row and Buy button on a collected patch open the in-app store on that patch's own page — the patch plus everything tagged to it, or one tap straight to checkout.

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

Before turning buy_patch on

  1. 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.
  2. Deploy the backend for the place line (optional; the page falls back to the Shopify title with no place).
  3. 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.
  4. 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 at FloatingTabBar.tsx:165). Six tabs or fewer are unchanged. Pinned by mobile/screen-tests/floating-tab-bar.test.tsx.
  5. Tagging script. Run shopify:tag-patch-related with --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-tag petrified-forest-route-66-tee in the same sitting.

User-facing surfaces

How it works

  1. Gate. PatchModalScreen computes shopHandle with shouldOfferPurchase: the patch is collected AND has a productHandle AND 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-03 buy_patch is 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). openShop is undefined otherwise (:356), and the screens show the Shop tab exactly when onShop is set (PatchDetailScreenV3.tsx:68).
  2. Tab list. buildVisibleTabs appends { id: 'shop', label: 'Shop' } last when shop is true (mobile/src/components/patch-detail-v2/useVisibleTabs.ts:41-60).
  3. Tap. Both screens intercept 'shop' in their tab-change handler: they call onShop() and return without setActive, 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). openShop pushes patchPurchaseRoute(...): /store-webview with path = patchShopPath(shopHandle) when buy_patch is on, /patch-interest/[id] when it is off (PatchModalScreen.tsx:202-205).
  4. Path. patchShopPath(handle) is storeHref('/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.
  5. WebView. store-webview runs the existing silent-login hop through /auth/native and lands on the path (mobile/app/store-webview.tsx:73; see mobile-store.md).
  6. 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). patchShopOutcome decides 404 vs render (patch-shop.ts:135-138, page.tsx:54). The patch's coll:<c> tags become collHandles (patch-shop.ts:26-33), and one Storefront search runs (getLinkedProducts(buildPatchShopQuery(...)), page.tsx:56-57).
  7. Assemble. assemblePatchShop removes the patch itself, de-duplicates by handle, drops anything not availableForSale and anything with no link tag, and orders by linkRank: 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.
  8. 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 one GalleryPiece field card (:54-63) and "More for <name>" with a SwagShelf (:65-70). It adds no card, button or shelf of its own.

Buy now (?buynow=1)

  1. Param. app/app/products/[handle]/page.tsx reads searchParams and passes buyNow = isBuyNow(...) (exactly buynow=1; a repeated param reads its first value) to PatchDetail, which hands it and product.variants[0].price to PatchBuyArea (store/lib/store/buy-now.ts).
  2. Gates, in order. Locked (not owned) → the LockedCallout, param ignored. Sold out → AddToCartButton's disabled "Sold out". Otherwise, with buyNow → BuyNowButton; without → AddToCartButton as before. In this /app tree only PatchBuyArea reads it, so sets ignore the param. Since 5706da7c MerchBuyArea reads it as well (client-side, isBuyNowSearch), which is how a hat's public page /products/<handle>?buynow=1 honours it — and a patch:-gated hat still locks (MerchBuyArea.tsx:104-122).
  3. 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) fires trackInitiateCheckout (numItems: 1, productHandle), calls the existing createCheckoutSession with one line (so the discount cookie, scout_source attribution and the zeroed-line sold-out check apply), and only once Shopify returns a checkout empties the Scout cart and sets window.location.href (runBuyNow). A failure re-enables the button, shows Couldn't start checkout: <message>, and leaves the cart untouched.
  4. Not consumed. The param stays in the URL, so coming back from checkout still shows Purchase.
  5. Vouchers apply (2026-11-06). BuyNowButton runs useCartPricing on 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 the scout_discount cookie before the checkout is built (runBuyNow's applyDiscount). 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

Key files

Mobile

Backend

Store

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Mobile

Backend

Store

Open questions