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
/shop— the whole merch catalogue,store/app/(store)/shop/page.tsx(revalidate = 300,:26;getShopProducts(),:39; noun "items",:55).- Every browse page, at any depth, from one catch-all route,
store/app/(store)/shop/[...slug]/page.tsx. The structure is Shopify collections plus acustom.parentmetafield, not a hardcoded registry:getShopTree(store/lib/shopify/collections.ts:57) →buildTree(store/lib/store/collection-tree.ts:94) →resolvePath(:172). Adding a category is an admin action, not a deploy. Pre-rendered per tree path (generateStaticParams,:53), ISR 300 s (:40). A URL that does not follow the real parent chain 404s (:108-110); a flat theme URL 308s to its nested home (legacyShopRedirect,store/lib/store/legacy-shop-routes.ts:15-20:/shop/national-parks→/shop/hats/national-parks,/shop/zoos→/shop/hats/zoos,/shop/route-66→/shop/tees/route-66). A lone child holding exactly its parent's products isnoindex, follow(:81-85,isRedundantChild,collection-tree.ts:210). - The Spotlight hero leads every shelf (
:144): one large product over a glow, with its price (compare-at struck), swatches or size chips as the product dictates, Add to cart, and "See all N <noun>". Which product leads isspotlightFor(store/lib/store/shelf-spotlight.ts:17) — the grid's first orderable card, passing over earned and sold-out products; on an all-earned shelf it leads with "View details" and never an Add to cart. On a shelf with two or more orderable hats the title is a native<select>park picker (parkPickerFor,store/lib/store/park-picker.ts:73), labels shortened to the park ("Zion"), colour carried across a switch and never a size (carriedSelections,:131).?hat=<handle>opens the shelf on that hat (resolveHat :107,heroHandle :117; read in the browser byuseHatParam,store/lib/store/use-hat-param.ts:18, because the page is static); an unknown, earned or sold-out handle is ignored. - Browse controls (
store/components/store/shop/ShelfBrowser.tsx, logic instore/lib/store/shelf-browse.ts): a count whose noun is the garment (shelfNoun :188,shelfCountLabel :202); "Show only the N I can buy", composed from the sametagBuyGate+ buyability the card's button uses (isShelfItemBuyable :76,isShelfItemOrderable :92,countOrderable :100,countUnlockable :118) and following ownership for a signed-in collector; a sort (SHELF_SORTS :60— Featured, price both ways, A–Z; any sort other than Featured flattens thegroup:sections); and 48 cards at a time with "Show more" (PAGE = 48,ShelfBrowser.tsx:37). All of it runs in the browser and holds no state in the URL, so a filtered shelf has no shareable link — the deliberate price of keeping shelves prerendered. - Sections. A grid holding more than one line renders one headed section
per
group:tag. Labels live inSHOP_GROUPS(store/lib/store/group.ts:59-68): National Park Leather Patch Truckers, National Park Trail Hats — Earned, National Park Leather Patch Beanies, Zoo Patch Hats — Earned, Snapback; an unregistered slug title-cases itself. The "— Earned" suffix appears only on groups whose products are actually gated (group.test.ts). - Empty shelf →
CategoryEmptyState([...slug]/page.tsx:196). - Shelf offer banner.
SHELF_OFFERS(store/lib/store/shelf-offer.ts:97-121) holds two bundles, both "2 for $35 · 3 for $50" off a $20 list price: Route 66 tees (shelvestees-route-66andtees, the latter labelled "Route 66 tees" because the Compass tee is outside the deal) and National Park leather-patch hats (beanies and truckers together; shelveshats-national-parks-beanies,hats-national-parks-trucker-hats,knit-hats). The discounts themselves are Shopify automatic discounts written bybackend/src/scripts/set-bundle-discounts.ts; the repo mirrors them, it is not the source of truth.store/scripts/verify-offers.ts(npm run verify:offers) buys every covered product at every tier in a real cart and fails if the price is wrong.
/products/[handle] — the product page
store/app/(store)/products/[handle]/page.tsx, ISR 300 s (:21). One route
for two product shapes:
- Apparel (
shop-tagged): swatches that double as the image picker, an angle strip for the chosen colourway (alt-text convention"<title> — <Colour> — <angle>",store/lib/store/variant-media.ts; side and back views currently hidden,:152), a full-screen viewer (MerchLightbox), a fixed-square image stage, the shopper's remembered size (localStoragekeyscout_size,store/lib/store/remembered-size.ts, labelled "Your usual size", never written by anything but an explicit pick), and Shopify compare-at sale strikes (productCompareAt,store/lib/shopify/sale-price.ts:95— a product strikes only when every variant is on sale at one price). - A patch or a collection runs the earned-not-bought / completed-set gates
(
patchProductGate,collectionProductGate,:66-70;COLLECTION_TYPE = 'Patch Collection',:23), documented in commerce-and-store.
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.
- In-app patch pages (
/app/products/<handle>?buynow=1) read the param on the server (isBuyNow,store/app/app/products/[handle]/page.tsx:34-35); the app's Buy button on patch detail builds that URL (patchBuyNowPath,mobile/src/config/store.ts:167-168).PatchBuyArea.tsx:74-75renders the button only when the variant is for sale and the patch is not locked. - Public merch pages (
/products/<handle>?buynow=1) are ISR-cached, soMerchBuyAreareads the param in the browser (isBuyNowSearch,:32;useBuyNow,MerchBuyArea.tsx:56-63) rather than fromsearchParams, which would make every product page dynamic. Built for paid-social ads (2026-10-27, Saint Louis Zoo snapback). - A shopper's voucher applies on both (2026-11-06): the button picks it by the
cart's rules, shows the price left to pay, and writes the code before the
checkout is built. Detail:
patch-shop.md"Buy now".
/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
/search— its own dynamic route (store/app/(store)/search/page.tsx) so/shopstays prerendered; terms are sanitised and wildcarded bysearchTerms(shop.ts), and a shopper cannot type pasttag:shop.- Header nav —
buildNavLinks(store/lib/store/nav-links.ts:77):/patchesfirst (PATCHES_LINK :63),/dropswhile a promo is tagged (DROPS_LINK :64), the Shopify tree two levels deep, then/shoplabelled Everything, last (CATALOGUE_LINK :75). Assembled instore/app/(store)/layout.tsx:22-30; an empty tree still leaves Patches and Everything standing. - Footer —
FOOTER_LINKS(store/lib/store/site-links.ts:67-73): Shipping & returns, About Scout, How to iron a patch, and Your Privacy Choices →https://scout-patches.com/privacy/choices(:72, served by the backend,backend/src/privacy/privacy-general.controller.ts:25); the business phone, email and address (CONTACT :29-40); social links; plus whichever legal pages Shopify actually holds text for. /privacy,/terms— render Shopify's shop-level policies throughgetShopPolicy(store/lib/store/shop-policies.server.ts:74), cached an hour. A policy the merchant has not written 404s and gets no footer link (policyFooterLinks,store/lib/store/shop-policies.ts:98); writing it in Shopify admin makes both appear with no deploy. Nine Shopify-shaped spellings (/privacy-policy,/pages/privacy, …) redirect to the real routes (store/lib/store/legacy-page-routes.ts:81)./shipping-returns,/about,/how-to-iron— written pages. The shipping and returns copy renders fromstore/lib/store/policy.ts(SHIPPING :35free domestic,RETURNS :53,deliveryLabel :117,returnsLabel :125), andMERCHANT_CENTER(:27) records what was declared to Google sopolicy.test.tscan hold the site to it.- Reviews — Judge.me, read server-side (
store/lib/judgeme.ts); a star row appears on a card only for a product with published reviews (ratingFor,store/lib/judgeme-shape.ts)./review/<handle>is a standalone form outside the store chrome.
How it works
- A shelf calls
getShopProducts(category?, q?)(store/lib/shopify/apparel-catalog.ts:114-127), which builds the Shopify Storefront query withbuildShopQuery(store/lib/store/shop.ts) — alwaystag:shop -tag:internal, a quotedtag:"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 usesgetCollectionProducts(collections.ts:147), which followshasNextPagethe same way. A Shopify error yields an empty list, never a broken page. - Products feed the shared card (
GalleryPiece, viatoSwagGalleryItem,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 withstore/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 adefault-color:<value>tag (store/lib/store/default-color.ts). - On a product page
MerchVariantProviderowns 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. - Checkout is Shopify's.
createCheckoutSessionrefuses a cart Shopify silently emptied (store/lib/shopify/cart-result.ts— aMERCHANDISE_*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). - Analytics.
AnalyticsTracker(mounted instore/app/layout.tsx:42) sendspageviewon every route change andpage_engagementon leaving it (AnalyticsTracker.tsx:23,37);SearchTrackersendssearchfrom the results page (search/page.tsx:57);store/lib/analytics/commerce.tssendsproduct_view,add_to_cart,checkout_start,image_swipe,filter_appliedandsort_changed(:30-74; the last two fromShelfBrowser.tsx:130,141). The event vocabulary isEVENT_TYPES(store/lib/analytics/track.ts:7-18). Events post to the store's ownPOST /api/analytics(store/app/api/analytics/route.ts:5-8), which forwards withSTORE_ANALYTICS_PROXY_TOKEN(handleAnalyticsProxy,store/lib/analytics/proxy-forward.ts:33) to the backend'sPOST /api/store/analytics(backend/src/store-analytics/store-analytics.controller.ts:23-30, 60 requests a minute per client behindProxyAwareThrottlerGuard). The visitor id is the website tracker's (scout_vid, withscout_session_idkept in step —store/lib/analytics/visitor.ts:15-16), minted client-side so pages stay cacheable (ensureSessionId,track.ts:41). First-touch attribution rides inscout_touch(store/lib/analytics/touch-cookie.ts:23). Numbers land in Admin → Analytics → Shop (analytics-hub). - 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 cookiescout_privacy_optout=1first, then Global Privacy Control (privacySignal :30).MetaPixel(store/app/layout.tsx:39) takes its id fromNEXT_PUBLIC_META_PIXEL_ID(MetaPixel.tsx:31) and withoutfbqevery 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. - Sign-in redirects. Every
next=the store honours — the sign-in page and the app's native sign-in bridge — goes throughsafeRelativeNext(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). - Crawling.
isIndexableProductdrivesrobots, JSON-LD, the sitemap and/feed.xml(Google Merchant Center, RSS 2.0 in theg:namespace, revalidated hourly —store/app/feed.xml/route.ts:16,store/lib/merchant-feed.ts:22). The sitemap carries every open editorial page,/dropsand the policy pages only while they exist, the browse tree, and the/patcheshub by the written-description rule (store/lib/sitemap-entries.ts);/account,/authand/cartare 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:
SHOP_CATEGORIES(store/lib/store/shop.ts) — nine entries:hats,knit-hats,tees,long-sleeve,sweaters,national-parks(tagNational Parks),zoos(tagzoo hat),route-66(tagroute 66),swag.long-sleeveandsweatershavenav: false(empty shelves);swaghashomeShelf: false(every swag item also carries a garment tag). Since the browse tree moved to Shopify, the registry survives for the home shelves, the nav fallback andbuildShopQuery.SHOP_GROUPS(store/lib/store/group.ts:59-68) — five section labels.SHELF_OFFERS(store/lib/store/shelf-offer.ts:97-121) — two bundles, mirroring Shopify's automatic discounts.derived-swatches.json— generated colours per product handle and option value; never hand-edited.- Browser-side only:
scout_size(remembered size), the cart cookie, the analytics cookiesscout_vid/scout_session_id/scout_touch, and the opt-out cookiescout_privacy_optout.
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
store/lib/store/shop.ts—SHOP_CATEGORIES,isPublicMerch(exactshop),buildShopQuery,searchTerms,productParentCrumb.store/lib/store/patch-hat.ts—tagBuyGate :106,productBuyGate :163, thepatch:link.store/lib/store/buy-area.ts:40-43— merch → swag → patch precedence.store/lib/store/indexable.ts:37-60— the one search-surface rule.store/lib/coming-soon.ts—OPEN_PREFIXES :48-57,isOpenPath :88,isComingSoonEnabled :105; bound instore/proxy.ts:51-61.
Shelves
store/app/(store)/shop/page.tsx,store/app/(store)/shop/[...slug]/page.tsx— the index and every browse page.store/lib/shopify/collections.ts—getShopTree :57,getCollectionProducts :147,getCollectionCounts :203.store/lib/store/collection-tree.ts—buildTree :94,resolvePath :172,isRedundantChild :210,treePaths :229.store/lib/store/legacy-shop-routes.ts,store/lib/store/legacy-page-routes.ts— 308s from the flat theme URLs and the Shopify-shaped page URLs.store/lib/shopify/apparel-catalog.ts:114-127—getShopProducts;store/lib/store/shelf-paging.ts— the onlyhasNextPageloop.store/components/store/shop/ShelfSpotlight.tsx,store/lib/store/shelf-spotlight.ts,store/lib/store/park-picker.ts,store/lib/store/use-hat-param.ts— the hero and its picker.store/components/store/shop/ShelfBrowser.tsx,store/lib/store/shelf-browse.ts— count, filter, sort, 48-at-a-time.store/lib/store/group.ts,store/components/store/shop/ShopSection.tsx,ShopGrid.tsx— sections and the grid.store/components/store/shop/ShelfHeader.tsx,ShelfBreadcrumb.tsx,ShelfOfferBanner.tsx,CategoryEmptyState.tsx.store/components/store/shop/ShopHero.tsx,EmberField.tsx— dormant: the composed banner, rendered by no page since 2026-09-22.store/lib/store/shelf-offer.ts,store/lib/store/offer-nudge.ts,backend/src/scripts/set-bundle-discounts.ts,store/scripts/verify-offers.ts— the bundles.
Product page
store/app/(store)/products/[handle]/page.tsx.store/components/store/MerchVariantProvider.tsx,VariantSwatches.tsx,MerchImageStage.tsx,AngleStrip.tsx,MerchLightbox.tsx,MobilePurchaseBar.tsx.store/components/store/MerchBuyArea.tsx,PatchBuyArea.tsx,BuyNowButton.tsx,store/lib/store/buy-now.ts— Add to cart vs "Purchase for $X".store/lib/store/variant-media.ts— the alt-text convention;HIDDEN_ANGLES :152.store/lib/store/remembered-size.ts,use-remembered-size.ts— the shopper's size.store/lib/shopify/sale-price.ts,store/components/store/PriceDisplay.tsx— compare-at strikes.store/lib/store/swatch-color.ts,derived-swatches.json,store/scripts/derive-swatches.py,store/lib/store/default-color.ts.store/lib/shopify/cart-result.ts— refuses a silently emptied cart.
Home, patches, chrome
store/app/(store)/page.tsx,store/components/store/home/HowScoutWorks.tsx(PLACES_FLOOR :24),PatchesTeaser.tsx,store/lib/store/home-shelves.ts.store/app/(store)/patches/**,store/lib/store/patches-index.ts,store/lib/store/user-state.server.ts,store/lib/store/locked-cta.ts.store/lib/store/nav-links.ts,store/lib/store/site-links.ts,store/lib/store/shop-policies.ts(+.server.ts),store/components/store/ShopPolicyPage.tsx,store/lib/store/policy.ts.store/lib/sitemap-entries.ts,store/lib/sitemap-pagination.ts,store/app/sitemap.ts,store/app/robots.ts,store/lib/merchant-feed.ts,store/app/feed.xml/route.ts,store/lib/seo.ts,store/lib/seo-text.ts.
Analytics, privacy, auth
store/lib/analytics/track.ts,visitor.ts,capture.ts,attribution.ts,touch-cookie.ts,commerce.ts,engagement.ts,search.ts,proxy-forward.ts;store/app/api/analytics/route.ts;store/components/analytics/AnalyticsTracker.tsx,SearchTracker.tsx.store/lib/analytics/privacy.ts,pixel-host.ts,meta-pixel.ts;store/components/analytics/MetaPixel.tsx.store/lib/auth/session-core.ts:70(safeRelativeNext),native-session.ts,session.ts.store/lib/feature-flags.ts— the three flags the store reads.
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
- Shopify answers "is this for sale?" and "can this be bought?" in two
places.
availableForSalereads inventory policy; a line can only enter a cart if its fulfilment location sits in a delivery profile with a rated zone. A print-on-demand vendor adding a location has stranded whole shelves twice (CustomCat 2026-09-18, Printify earlier) while every health check read green.backend/src/scripts/shop-purchasability.live.spec.tscarts one product per vendor against the real store; it is live-tier, not CI. - Cart quantity caps are per vendor and none is the stock number: an untracked print-on-demand variant accepts any quantity, a tracked one stops at Shopify's own 50, and only Scout's in-house stock caps at real units. Over-asking errors on the cart page rather than shipping fewer.
- A filtered or sorted shelf has no URL, cannot be bookmarked, and is not what a crawler sees. The whole collection is fetched and narrowed in the browser; nothing filters, sorts or pages server-side.
?hat=and?buynow=1on static pages apply after hydration, so a linked visitor sees the default hat / Add to cart for a moment first.- Side and back photos are hidden on every hat (
HIDDEN_ANGLES, 2026-10-10). Nothing was deleted; emptying the array restores them. - The angle strip is opt-in per product via alt text, and alt text is validated nowhere. A misspelt colour means a silently missing strip.
- A sale is whatever compare-at Shopify holds right now. Nothing here schedules, starts or ends one, and a partial sale (some variants only) shows no strike at all.
- The remembered size is one
localStoragekey on this origin: not an account setting, never sent anywhere, shared on a shared device, forgotten with site data. It never guesses a fit — a size the product lacks or that is sold out in the showing colour leaves the picker blank. - The home hero's
PLACES_FLOORunderstates the catalog by more than half (see/above). True, but stale. /patches/c/<handle>loads a whole collection in one request and nine collections now exceed the 48 that was assumed to be the ceiling.- 1,304 live patches have no Shopify product, so
/patchesshows places nobody can order even after earning them. /terms404s unless Shopify holds a terms of service. It did not as of 2026-09-21; whether it does today is not verifiable from the repo. The route and footer link appear by themselves the moment it is written./carthas no page-specific<title>('use client'page, nometadata), and the home page has no canonical tag, so/?utm_source=…variants are indexable duplicates.- Shopify images are not sized, lazy-loaded or
srcset'd on shelves or product pages (/patchesusesnext/imageand is the exception). AcdnImagechange shipped and was reverted within an hour on 2026-09-20 (85e8133b/4f288e31) with no reason recorded; do not redo it without finding out why. - Store-only merch is never in the app. Animal Hats and the other product-only
sets are
adminOnly; the content sync strips them before the app sees them. Buying one adds no patch, badge or progress to any account.
What this feature does NOT do
- Does not sell a patch to a stranger.
/patchesshows every patch to everyone, but a patch is orderable only when its collection ispublicPurchase(backend/prisma/schema.prisma:52) or the signed-in shopper earned it in the app. That is the product, not a limitation. - Does not sell a patch hat to a stranger either. A hat carrying a
patch:tag is gated by the patch it carries; the page shows the hat, the colourways and the price, and offers no purchase until the patch is earned. Hats with nopatch:tag — the vegan-leather National Park truckers and beanies, the Animal Hats, the snapback — are open to anyone. - Does not gate at checkout. The earned-not-bought rule lives in the
Next.js UI layer (
buyAreaFor,productBuyGate); Shopify's cart has no concept of "earned". See commerce-and-store. - Does not switch hats on or off with
storefront_hats_enabled. That flag (livefalse) only changes the sitemap's product query. The Hat Studio composer that flag once fronted is gone from the routes;hat_studio_combois not read bystore/, andHatStage.tsx/HatComposite.tsxare unmounted leftovers. - Does not read UTM, referrer or ad ids to choose a hat — only
?hat=<handle>. The park picker is a plain text list: no thumbnails, no search, no earned or sold-out hats, never a size carried across. - Does not run the Meta Pixel for an opted-out visitor, a Global Privacy
Control browser, a visit that came through the app, or any host outside
PIXEL_ALLOWED_HOSTS. Does not stop its own first-party analytics on opt-out — it records the signal on each event instead. The opt-out page is the backend's, atscout-patches.com/privacy/choices. - Does not sell packs, open-patch sets or store exclusives on the web.
/collections,/open-patches,/featured,/store-exclusives,/packs,/gear,/displayand/field-recordswere deleted when/shopsuperseded them (store/lib/coming-soon.ts:40-44), not walled. Pack commerce is the in-app store (mobile-store). - Does not offer wholesale, bulk, custom or B2B. No page, no MOQ, no
invoice path; a bulk enquiry reaches the phone number on
/about. - Does not discount anything made in house. Patches and Trail Hats carry no bundle; only the Route 66 tees and the National Park leather-patch hats do, and a single item is only ever discounted once.
- Does not print a gift note on anything. The cart's gift note reaches the Shopify order and stops there; print-on-demand ships direct.
- Does not publish terms of service, a refund policy or a shipping policy as legal documents unless Shopify holds them (see Edge cases).
- Does not hold product counts, stock or prices in the repo. "How many hats
are for sale", "what is in stock", "how many rows Merchant Center ingested"
are Shopify or Google state and not verifiable from the repository; the
only in-repo snapshot is
derived-swatches.json(48 products, 202 colourways when last generated), which is a cache, not an inventory. - Does not populate itself. A product appears in the web store only once
someone tags it
shopin Shopify admin — that is a content decision, kept manual on purpose. - Does not change the in-app
/apptree, the per-patch Shop page or Patch Club; each is documented separately. - Does not change Shopify publications. Products stay on the Scout Headless channel only; nothing moves a product onto the Online Store channel.
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/.
store/lib/store/shop.test.ts— every product query is gated ontag:shopand excludesinternal; theme categories resolve to their catalogue tag; the registry's slugs, order andhomeShelfrules; the dual-category snapback;searchTermssanitising.store/lib/store/buy-now.test.ts,buy-now-render.test.ts—isBuyNow(exactly1, first value of a repeated param),isBuyNowSearch, the label, the cart emptied only after the checkout exists, the double-tap latch and its reset; the rendered merch and patch buy areas show "Purchase for $X" only underbuynow=1and only for a variant that is for sale.store/lib/store/patch-hat.test.ts,buy-area.test.ts,indexable.test.ts— the gate, the precedence (merch beats swag; mutation-verified), and that a gated patch hat and an earned-only patch are never indexable.store/lib/store/shelf-browse.test.ts— 28 cases: paired filter branches, the gate's four states, buyable vs orderable, sort never mutating, the count label, the breadcrumb trail (titles, real paths).store/lib/store/shelf-spotlight.test.ts,shelf-spotlight-render.test.ts,park-picker.test.ts— which product leads, the rendered hero through the real providers, the picker's labels and exclusions,?hat=handling, colour carried and size never.store/lib/store/collection-tree.test.ts— 20 cases: tree building, a missing parent, a cycle, a self-parent, path resolution along the real chain, count-based redundancy.store/lib/store/shelf-paging.test.ts,store/lib/shopify/collections.test.ts,apparel-catalog.test.ts— every product read pages to the end of the connection; the call sites are pinned tocollectPages.store/lib/store/shelf-offer.test.ts(22 cases, including every basket from 2 to 6 items held above its line's profit floor),offer-nudge.test.ts,shelf-header.test.ts;backend/src/scripts/set-bundle-discounts.spec.ts.store/lib/shopify/sale-price.test.ts,store/lib/store/price-display.test.ts,queries.test.ts— one strike, only when every variant is on sale; every product query selectscompareAtPrice.store/lib/store/remembered-size.test.ts— 12 cases, including the refusals.store/lib/store/variant-media.test.ts— the alt-text convention, "Blackberry is not Black", andshownImageshiding side/back by whole segment without ever emptying a product.store/lib/store/nav-links.test.ts,site-links.test.ts(the retired personal phone and address never appear; the privacy-choices link is present),shop-policies.test.ts,legacy-shop-routes.test.ts,legacy-page-routes.test.ts,policy.test.ts(the site never promises more than was declared to Merchant Center).store/lib/store/home-facts.test.ts— the home page's claims against rendered HTML: free app,3,500+ places,$8 / 3 inches / iron-on, the buy-now-vs-earn legend, never "launching soon", and no mounted home component links to the 404/products.store/lib/store/patches-index.test.ts— a campaign section never carries a patch list;visibleCollectionsdropsadminOnlyand empty collections; ownership partitioning.store/lib/analytics/*.test.ts—privacy.test.ts(opt-out beats GPC; the pixel never loads in-app, on an unknown host or under a signal; the signal rides on events),pixel-host.test.ts,visitor.test.ts(one id shared with the website tracker),commerce.test.ts,engagement.test.ts,search.test.ts,proxy-forward.test.ts(the real proxy handler, the token header, the backend's status returned as-is),capture.test.ts,attribution.test.ts,touch-cookie.test.ts.store/lib/auth/session.test.ts,native-session.test.ts—safeRelativeNextrefuses control characters,//host, dot segments that normalise to one, and anything not same-origin.store/lib/shopify/cart-result.test.ts— a silently emptied cart is an error, never a checkout URL; a non-merchandise warning still checks out.store/lib/sitemap-entries.test.ts,sitemap-pagination.test.ts,store/lib/merchant-feed.test.ts(17 cases),store/lib/seo.test.ts,seo-og.test.ts,seo-title.test.ts,store/lib/coming-soon.test.ts.backend/src/scripts/shop-purchasability.live.spec.ts— live tier, not CI.store/scripts/verify-offers.ts— live, opt-in (npm run verify:offers).
Open questions
- No live fetch of
shop.scout-patches.comwas made for this doc. The wall being down, the tagged product set, stock, and which legal policies Shopify holds are all read from code comments and the live app feed, not from the deployed storefront. - Product counts. How many products carry
shop, how many are orderable by a stranger, how many variants reach Merchant Center and whether any are disapproved: Shopify Admin and Google Merchant Center state, not verifiable from the repo. limited_inventory_modein production was not re-read; its safe default istrue, under which the sitemap submits no product URLs.- Whether the 1,304 state-park patches get Shopify products, and when
PLACES_FLOORis raised to a true-today floor. - Why
85e8133b(image sizing) was reverted is still unrecorded.