Scout — Full Product Context → feature documentation

Merch Storefront — Tag-Driven Web Shop

The public web storefront (store/, shop.scout-patches.com) sells apparel — hats, knit beanies, tees, long-sleeve shirts, sweatshirts and branded swag — through a tag-driven…

Summary

The public web storefront (store/, shop.scout-patches.com) sells apparel — hats, knit beanies, tees, long-sleeve shirts, sweatshirts and branded swag — through a tag-driven catalogue read straight from Shopify. Two tags decide everything: shop opts a product into the web store at all, and shop:<category> places it on one or more of six category pages (store/lib/store/shop.ts:1-18). The wall that used to hide the whole shopfront behind a "coming soon" page is now open for this apparel store — home, /shop, /shop/[category], /products/[handle] and /cart are all reachable — while patch and pack commerce stays walled off (store/lib/coming-soon.ts:29-41). The tagging pass HAS since been run: 27 products carry shop as of 2026-09-16 — 14 hats, 12 knit beanies, a tee and a snapback (verified against the live Admin API, not inferred).

Status

Shipped to develop. The catalogue is now populated: 27 Shopify products carry shop (see Summary), so /shop, /shop/hats and /shop/knit-hats render real grids. /shop/tees, /shop/long-sleeve, /shop/sweaters and /shop/swag are still thin or empty and render the empty state until more products are tagged — a manual Shopify step (see CLAUDE.md's "Content publishing" section for why that stays Alan's job, not an agent's).

Stock is the separate question from tagging: only the Saint Louis Zoo trail hat has inventory (10 units across three colourways). Every leather-patch hat and beanie is tagged, visible and at zero stock.

User-facing surfaces

How it works

  1. A category page (or the home shelf, or /shop) calls getShopProducts(category?), which builds a Shopify Storefront product search query via buildShopQuery and fetches through the shared getShelfProducts helper used by every other shelf (swag, promo) — same never-throws contract: a Shopify error yields an empty list rather than a broken page (store/lib/shopify/apparel-catalog.ts:63-70, :75-97).
  2. buildShopQuery always prepends tag:shop and -tag:internal; a category argument appends tag:shop:<category> (store/lib/store/shop.ts:111-119). tag:shop is the opt-in gate — a product with no shop tag can never appear in the web store, however else it is tagged.
  3. Category pages are pre-rendered at build via generateStaticParams (one per entry in SHOP_CATEGORIES) and revalidate every 300s (ISR), matching every other catalogue route in the store (store/app/(store)/shop/[category]/page.tsx:16, :27-29).
  4. When a category currently has zero products, the page renders CategoryEmptyState instead of the grid, and generateMetadata sets robots: { index: false } for that response — an empty category is thin content and should not be crawled (store/app/(store)/shop/[category]/page.tsx:44, :66-70).
  5. The result feeds the same product card (GalleryPiece, via toSwagGalleryItem) used everywhere else in the store, so apparel gets no bespoke card that could drift from the patch/swag cards (store/lib/store/swag-gallery.ts:1-13). Each card carries a star row (★★★★★ 5.0 (1)) when that product has published Judge.me reviews. Ratings are fetched ONCE per page with getRatingsByProduct() and looked up per card with ratingFor — never one request per card, which would be two Judge.me round trips per product. ShopGrid takes SwagItems and runs the adapter itself precisely so a call site cannot hand it cards whose productId (the lookup key) has already been adapted away.
  6. On a product page, MerchVariantProvider owns BOTH the colour selection and the angle index, and the swatches, the image stage, the angle strip and the full-screen viewer all read it — four views of one state rather than four copies. Changing colour resets the angle to that colour's first photo, derived during render rather than reset in an effect so the strip and the image can never be one paint out of step. Which photos belong to which colour is read from image alt text, formatted "<title> — <Colour> — <angle>" and matched per whole em-dash-separated segment (store/lib/store/variant-media.ts). This is a Shopify content convention, not a schema change: tagging a product's photos is an alt-text edit, and a product that does not use it degrades to one photo per colour. Alt text rather than Shopify variant media because the Storefront API exposes only ONE image per variant — a variant's full media list exists in the Admin API and is simply not readable by the storefront.
  7. The mobile buy bar (position: fixed below sm) measures itself, and MobilePurchaseBarSpacer reserves exactly that height at the end of the page (store/components/store/MobilePurchaseBar.tsx). It replaced two hardcoded spacer heights that were 144px short once a colour picker went into the bar.
  8. Whether a category is open to crawlers moves with the wall (isOpenPath) and the same "has at least one product" count that drives the empty state — sitemapPaths filters candidate URLs through isOpenPath and drops any category slug not in populatedCategories (store/lib/sitemap-entries.ts:43-60; the count is computed once per category in store/app/sitemap.ts).

Data model

No new Prisma models. The registry is a static, in-repo array — not a database table:

// store/lib/store/shop.ts:45-90
SHOP_CATEGORIES: {
  slug: string;       // URL segment and tag suffix, e.g. 'hats'
  navLabel: string;    // nav bar label
  title: string;        // <h1> / <title>
  description: string;  // meta description / page subhead
  homeShelf: boolean;    // whether home renders a shelf for it
}[]

The six entries, in nav order: hats, knit-hats, tees, long-sleeve, sweaters, swag (store/lib/store/shop.ts:45-90; order and slugs are asserted by store/lib/store/shop.test.ts). swag is the only entry with homeShelf: false, because every swag item also carries a garment tag and would otherwise double up with another shelf on the same screen (store/lib/store/shop.ts:78-84).

The actual products live in Shopify, identified purely by tag — there is no Scout database row per apparel product the way there is for a patch. Category membership is not exclusive: a Scout-branded snapback can carry shop, shop:hats and shop:swag at once and appear on both category pages (store/lib/store/shop.ts:10-13).

API surface

No new backend endpoints. This project is entirely store/ (the Next.js storefront) talking directly to the Shopify Storefront GraphQL API (GET_APPAREL_LISTING, called from getShelfProducts, store/lib/shopify/apparel-catalog.ts:75). No Scout backend route was added or changed.

Key files

Google Merchant Center

/feed.xml is an RSS 2.0 feed in the http://base.google.com/ns/1.0 namespace, revalidated hourly. It is live in Merchant Center account 5856267263 (US / English, daily fetch), with all 480 rows ingested and none disapproved. Shipping is declared free, returns 30 days by mail with return postage at the customer's cost.

Why self-hosted rather than Shopify's Google & YouTube app: that app syncs from the Online Store channel, and Scout deliberately publishes to none of the browsable publications. BROWSABLE_PUBLICATION_IDS in backend/src/scripts/lib/shopify-publications.ts actively guards against it, because the Liquid theme has never heard of the internal tag — publishing there makes Hat Studio blanks publicly buyable. The standard path is closed by design, not merely unconfigured.

Feed membership uses isIndexableProduct, the same predicate as the sitemap and the PDP robots tag, so the three cannot drift. Advertising an earned-only patch would be a Merchant Center policy violation, not merely a poor listing.

Against the live catalogue: 2,431 products in, 480 rows out (one per buyable variant of the 48 buyable products), zero patches, zero missing images, zero unescaped ampersands.

Apparel attributes. Merchant Center flagged all 480 rows with "Missing age group / color / gender / size" — required attributes for apparel. The data was in Shopify all along; the feed simply did not emit it. optionValue() reads variants.selectedOptions case-insensitively on the option NAME, because those names are merchant-entered (Color, color, Colour). age_group and gender are stated flat as adult / unisex: nothing in the catalogue is sized by gender.

A missing option is OMITTED rather than emitted empty — Merchant Center reads a blank <g:size></g:size> as an invalid value, but a missing one as absent. Live: 480/480 carry colour, age group and gender; 444 carry size.

Known wrinkle — g:size on the leather-patch beanies. 345 tee rows carry real garment sizes (S4XL), which is where Google requires size. But 96 beanie rows emit <g:size>Small Circle</g:size>, because those products use a Shopify option literally named Size to describe the patch, not the hat. A further 12 zoo trail hats emit no size at all, having only a Color option where every other hat carries Size: One size.

Both are catalogue-naming problems, not feed bugs, and the fix belongs in Shopify: rename the beanie option to Patch size, and add Size: One size to the 12 trail hats. Size is only a REQUIRED attribute for Apparel & Accessories > Clothing and > Shoes; hats are Clothing Accessories, so neither risks disapproval — they cost filter accuracy.

Configuration and flags

No new environment variable or feature flag. The existing STORE_COMING_SOON flag (read by isComingSoonEnabled, store/lib/coming-soon.ts:105-108) still governs whether the wall is up at all; when it is, OPEN_PREFIXES decides what stays reachable, and the merch store's prefixes (/shop, /products, /cart, /account) are in that allowlist (store/lib/coming-soon.ts:50-60).

Edge cases and known limits

What this feature does NOT do

Tests that cover it

No Playwright/Maestro end-to-end coverage exists for the shop pages; the tests above are unit-level (node:test) against the pure query/routing functions, matching this codebase's pattern for store/lib/**.

Open questions