Scout — Full Product Context → feature documentation

Merch Storefront — the public web shop

The public web storefront (store/, shop.scout-patches.com): a tag-driven apparel shop (hats, beanies, tees, swag) read straight from Shopify, the /patches hub showing every Scout patch, buy-now links for ads and the app, and first-party analytics with a privacy-gated Meta Pixel.

Summary

The public web storefront (store/, a Next.js app at shop.scout-patches.com — store/lib/seo.ts:7) sells apparel through a catalogue read straight from Shopify. Two tags decide everything: shop opts a product into the web store at all, and shop:<slug> (or a theme tag the product already carries) places it on a category page (store/lib/store/shop.ts). A product with no shop tag is invisible on the web store however else it is tagged.

What is on sale is Shopify state, not repo state. The code does not hold a product list, a stock number or a price; it holds the rules — which tag means "for sale", which tag means "earn the patch first", how a shelf is grouped, which product leads a page, when a Meta Pixel may load. Anything below stated as a count was read from the live catalog feed on 2026-10-29 or is flagged as not verifiable from the repository.

Three things live beside the apparel shop on the same site and have their own docs: the signed-in in-app store at /app (mobile-store), the per-patch Shop page /app/patch/<handle> the app's Shop tab opens (patch-shop), and the Patch Club join page (patch-club). Patch and collection commerce rules are in commerce-and-store.

Status

Live. The coming-soon wall is down: STORE_COMING_SOON is read as the exact string 'true' (store/lib/coming-soon.ts:105-108) and the module's own header records the wall as "currently DOWN" (:7-8). Hats and tees have been on public sale since the store opened on 2026-09-16; the app's buy_patch flag reads false in production (2026-11-06), so the app's patch buttons open a "would you buy this?" questionnaire instead of /app/patch/<handle> (patch-shop). No live fetch of the storefront was made for this doc — see Open questions.

Changed since the last revision of this doc (2026-10-11):

Date Change Where
2026-10-10 Hats' side and back photos hidden from the product page (still on Shopify) HIDDEN_ANGLES = ['side', 'back'], store/lib/store/variant-media.ts:152
2026-10-10/11 Shelf pages open with a Spotlight hero; its title is a park picker with ?hat=<handle> links ShelfSpotlight.tsx, store/lib/store/park-picker.ts
2026-10-11 The app's Shop tab lands on /app/patch/<handle> patch-shop
2026-10-19 First-party store analytics: browse events, in-app product views, a visitor id shared with the website tracker (9361033a) store/lib/analytics/*, store/app/api/analytics/route.ts
2026-10-19 Meta Pixel gated on host, privacy signal and in-app visit; "Your Privacy Choices" footer link (a335acc8, 61a532c7) store/lib/analytics/privacy.ts, store/lib/store/site-links.ts:72
2026-10-19 Sign-in next= goes through safeRelativeNext (open-redirect fixes 586a7264, 5fe17e09, df2bf53d) store/lib/auth/session-core.ts:70
2026-10-25 Buy now — ?buynow=1 turns a patch's buy area into "Purchase for $X" straight to checkout (56330834) store/lib/store/buy-now.ts
2026-10-27 Buy now on the public product page for shop-tagged merch, so an ad lands one tap from checkout (5706da7c) store/components/store/MerchBuyArea.tsx:56-63
2026-10-27/29 Patch Club join page at /app/patch-club (flag patch_club, off) patch-club

User-facing surfaces

/ — home

store/app/(store)/page.tsx. In order: the hero (proposition, two buttons — /shop and the universal GET_APP_URL — and PdpTrustStrip, the same policy-derived shipping/returns chips the product page shows), HowScoutWorks (:196), the /patches band PatchesTeaser (:204), one shelf per category flagged homeShelf: true (:92-101, four products each, deduplicated across the page by dedupeShelves, store/lib/store/home-shelves.ts), reviews (:230) and the app CTA (:255).

The field-guide size on the home page is a floor. PLACES_FLOOR reads '3,500+' (store/components/store/home/HowScoutWorks.tsx:24), raised on 2026-10-29 from the '1,400+' set on 2026-09-21 against a 1,784-place catalog. The live catalog the app downloads has 3,553 places (sync feed, 2026-10-29). A floor can only understate, never lie; re-raise it when the catalog next grows past the next round number. The /patches band states an exact collection count instead (PatchesTeaser.tsx:58-60: "N collections — … $8 each, 3 inches, embroidered, iron-on").

/shop and /shop/<garment>/<subject>/<line> — the shelves

/products/[handle] — the product page

store/app/(store)/products/[handle]/page.tsx, ISR 300 s (:21). One route for two product shapes:

Which buy control renders is buyAreaFor (store/lib/store/buy-area.ts:40-43): merch (the shop tag) beats swag (product type) beats patch (the gate). A patch hat is the exception inside merch: a patch:<patch-product-handle> tag (PATCH_TAG_PREFIX, store/lib/store/patch-hat.ts:47) links it to its patch, and productBuyGate (:163) resolves the tag, the bare shop tag and the backend's answer for the patch itself once, driving both the visible control and the ?autocart=1 deep link. A locked hat keeps its swatches and swaps Add-to-cart for LockedCallout.

Search surface rule. isIndexableProduct (store/lib/store/indexable.ts:37-60) decides robots (:47-48), Product JSON-LD (:83), the sitemap and the Merchant Center feed from one predicate: a gated patch hat is excluded first (tagBuyGate(tags).requiresUnlock), then anything resolving to the patch buy area. Earned-only patch pages are noindex, follow; a page with no public offer emits no Product schema.

Buy now — ?buynow=1

store/lib/store/buy-now.ts. With buynow=1 in the query (BUY_NOW_PARAM, :21) the buy area shows "Purchase for $X" (buyNowLabel :36) and one tap builds a Shopify checkout holding that item alone, empties the Scout cart only once the checkout exists, and leaves for it (runBuyNow :50). A synchronous latch stops a double tap building two carts (buyNowOnce :64). Every other way onto the page keeps Add to cart.

/patches — every Scout patch, on the web

Three tiers, only the last loading patch-level data:

Route Rendering Loads
/patches ISR 300 s (store/app/(store)/patches/page.tsx:26) One section per campaign: four member collections (PREVIEW_PER_CAMPAIGN, store/lib/store/patches-index.ts:30), counts, thumbnails. No patches
/patches/<campaign> ISR 300 s ([campaign]/page.tsx:16) That campaign's collections. No patches
/patches/c/<handle> force-dynamic (c/[handle]/page.tsx:30) The patch grid and the gate

Collections come from the backend's GET /api/store/collections (backend/src/store-collections/store-collections.controller.ts:11-21); visibleCollections drops adminOnly and zero-patch collections (patches-index.ts:56-61). The gate resolves on the server: the backend splits a collection anonymously into buyable (publicPurchase) and locked patches, getOwnedPatchHandles() (store/lib/store/user-state.server.ts:67) reads the signed-in shopper's patches with the session's bearer token, and partitionByOwnership() (patches-index.ts:134) upgrades the ones they earned — no flash of "locked", works with JavaScript off, fails closed. Every patch is visible to everyone; a locked one shows its art dimmed, where it is, how to unlock it, and the app CTA (LockedCallout, closing line from store/lib/store/locked-cta.ts).

Live shape (sync feed, 2026-10-29): 225 public collections in 24 campaigns; 90 collections carry a written description. Only those 90 earn a /patches/c/<handle> sitemap entry (patchSitemapPaths, store/lib/sitemap-entries.ts:100-107); the others fall back to "Every Scout patch in <name>." and are kept out of the sitemap as thin pages.

The leaf's "no pagination" assumption no longer holds. It was written when no collection exceeded 48 patches (c/[handle]/page.tsx:18). Nine now do — Florida State Parks has 123, California 118, New York 95 — and the leaf still loads the whole collection in one request. It works; it is no longer bounded by the data model.

1,304 live patches — every state park — have no Shopify product yet (product_handle null in the sync feed). They are earnable in the app and visible on /patches; there is nothing to order for them until products exist.

Search, nav, footer, policies

How it works

  1. A shelf calls getShopProducts(category?, q?) (store/lib/shopify/apparel-catalog.ts:114-127), which builds the Shopify Storefront query with buildShopQuery (store/lib/store/shop.ts) — always tag:shop -tag:internal, a quoted tag:"shop:<slug>" or the theme category's own tag, optional sanitised search terms — and pages to the end of the connection (UNBOUNDED, collectPages, store/lib/store/shelf-paging.ts). A browse-tree leaf uses getCollectionProducts (collections.ts:147), which follows hasNextPage the same way. A Shopify error yields an empty list, never a broken page.
  2. Products feed the shared card (GalleryPiece, via toSwagGalleryItem, store/lib/store/swag-gallery.ts): price on the art, a fixed badge row, colour chips whose colour comes from Shopify's swatch field, else a colour sampled offline from the variant photo (store/lib/store/swatch-color.ts, derived-swatches.json — a generated snapshot of 48 products / 202 colourways; regenerate with store/scripts/derive-swatches.py), else the photo. Which colourway a product opens on is a hash of its handle (store/lib/shopify/variant-options.ts), overridable per product by a default-color:<value> tag (store/lib/store/default-color.ts).
  3. On a product page MerchVariantProvider owns colour and angle; swatches, stage, strip and lightbox read one state. Photos are matched to colours by alt text (variant-media.ts), never by Shopify variant media, because the Storefront API exposes one image per variant.
  4. Checkout is Shopify's. createCheckoutSession refuses a cart Shopify silently emptied (store/lib/shopify/cart-result.ts — a MERCHANDISE_* warning or a quantity shortfall is an error, not a checkout URL). The cart drawer names the bundle tier reached and nudges toward the next one (store/lib/store/offer-nudge.ts).
  5. Analytics. AnalyticsTracker (mounted in store/app/layout.tsx:42) sends pageview on every route change and page_engagement on leaving it (AnalyticsTracker.tsx:23,37); SearchTracker sends search from the results page (search/page.tsx:57); store/lib/analytics/commerce.ts sends product_view, add_to_cart, checkout_start, image_swipe, filter_applied and sort_changed (:30-74; the last two from ShelfBrowser.tsx:130,141). The event vocabulary is EVENT_TYPES (store/lib/analytics/track.ts:7-18). Events post to the store's own POST /api/analytics (store/app/api/analytics/route.ts:5-8), which forwards with STORE_ANALYTICS_PROXY_TOKEN (handleAnalyticsProxy, store/lib/analytics/proxy-forward.ts:33) to the backend's POST /api/store/analytics (backend/src/store-analytics/store-analytics.controller.ts:23-30, 60 requests a minute per client behind ProxyAwareThrottlerGuard). The visitor id is the website tracker's (scout_vid, with scout_session_id kept in step — store/lib/analytics/visitor.ts:15-16), minted client-side so pages stay cacheable (ensureSessionId, track.ts:41). First-touch attribution rides in scout_touch (store/lib/analytics/touch-cookie.ts:23). Numbers land in Admin → Analytics → Shop (analytics-hub).
  6. Privacy and the Meta Pixel. shouldLoadPixel (store/lib/analytics/privacy.ts:81-90) loads the pixel only on an allowed host (PIXEL_ALLOWED_HOSTS, store/lib/analytics/pixel-host.ts:29-36: shop.scout-patches.com, scout-patches.com, www., checkout.), with no privacy signal, and never for a visit that came through the app (/app, /auth/native, remembered for the session — isInAppVisit :57). The signal is the opt-out cookie scout_privacy_optout=1 first, then Global Privacy Control (privacySignal :30). MetaPixel (store/app/layout.tsx:39) takes its id from NEXT_PUBLIC_META_PIXEL_ID (MetaPixel.tsx:31) and without fbq every Meta event is a no-op. First-party analytics still runs under opt-out or GPC; the signal is recorded on the event (withPrivacy :93) so the hub can show it. Full rules: web-analytics.
  7. Sign-in redirects. Every next= the store honours — the sign-in page and the app's native sign-in bridge — goes through safeRelativeNext (store/lib/auth/session-core.ts:70), which rejects anything that is not a same-origin relative path (control characters, //host, dot segments that normalise to one).
  8. Crawling. isIndexableProduct drives robots, JSON-LD, the sitemap and /feed.xml (Google Merchant Center, RSS 2.0 in the g: namespace, revalidated hourly — store/app/feed.xml/route.ts:16, store/lib/merchant-feed.ts:22). The sitemap carries every open editorial page, /drops and the policy pages only while they exist, the browse tree, and the /patches hub by the written-description rule (store/lib/sitemap-entries.ts); /account, /auth and /cart are disallowed (store/app/robots.ts:18-19,30-31).

Data model

No Prisma model belongs to this feature. Products, variants, prices, stock, collections, the custom.parent metafield, compare-at prices and the bundle discounts are Shopify state. The repo holds:

The patch side reads the backend: GET /api/store/collections and /:handle, GET /api/store/catalog/:handle, GET /api/store/patches and /public, GET /api/store/inventory-visibility (backend/src/store-collections/*.controller.ts), and GET /api/store/feature-flags.

API surface

Endpoint Side Auth Notes
Shopify Storefront GraphQL Shopify public Storefront token every product, collection, cart and checkout read/write
POST /api/analytics store none; forwards with STORE_ANALYTICS_PROXY_TOKEN store/app/api/analytics/route.ts → backend POST /api/store/analytics (throttled 60/min)
GET /api/store/feature-flags backend none store/lib/feature-flags.ts:28, revalidated every 30 s, safe defaults on any failure
GET /api/store/collections[/:handle] backend none; a bearer token upgrades ownership on the leaf the /patches hub
GET /api/store/my-patches backend bearer getOwnedPatchHandles
GET /feed.xml store none Merchant Center feed

Key files

The contract

Shelves

Product page

Home, patches, chrome

Analytics, privacy, auth

Configuration and flags

Feature flags the store reads (store/lib/feature-flags.ts:4-15, from GET /api/store/feature-flags, safe defaults when the backend is unreachable):

Flag Safe default What it does in store/ Live (2026-10-29)
limited_inventory_mode true robots.ts:10 and sitemap.ts:102: in this mode the sitemap carries no product URLs not re-read for this doc
storefront_hats_enabled false Only which product query the sitemap walks (sitemap.ts:116: off excludes product_type:"Trail Hat"). It does not show or hide any shelf — hats sell through the shop tag regardless false
patch_club false The Patch Club row on a patch page (patch-club) off

buy_patch (live false) is read by the app, not the store; it decides whether the app's Shop tab opens /app/patch/<handle> or the questionnaire. The flags storefront_curated_mvp (live false) and hat_studio_combo (live false) are not read anywhere under store/.

Environment (store/.env.example): NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN, NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN, BACKEND_API_URL, NEXT_PUBLIC_BASE_URL (defaults to https://shop.scout-patches.com), STORE_COMING_SOON (exact 'true' raises the wall; STORE_PREVIEW_TOKEN hands out a bypass cookie), STORE_ANALYTICS_PROXY_TOKEN, NEXT_PUBLIC_META_PIXEL_ID, JUDGEME_PRIVATE_TOKEN, JUDGEME_SHOP_DOMAIN, ADMIN_EMAILS, NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID, PATCH_CLUB_VARIANT_ID, PATCH_CLUB_SELLING_PLAN_ID.

Everything else that changes what is on sale — tags, collections, the custom.parent metafield (whose definition must be storefront: PUBLIC_READ or the tree silently flattens), compare-at prices, automatic discounts, policies, stock — is set in Shopify admin and takes effect on the next revalidate with no deploy.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

cd store && npm test runs node --test over lib/**/*.test.ts (store/package.json:10) — 172 test files under lib/ at this revision. No Playwright or Maestro end-to-end coverage exists for the shop pages; the browser-rendered ones below mount real components through store/test-support/.

Open questions