From 4af9644d5c46a41180e42a710c0cf26939c58243 Mon Sep 17 00:00:00 2001 From: hta218 Date: Mon, 21 Sep 2026 16:59:07 +0700 Subject: [PATCH 01/49] Add the collection route filters spec --- .../README.md | 29 ++++++ .../plan.md | 89 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 .weaverse/specs/2026-09-21--collection-route-filters/README.md create mode 100644 .weaverse/specs/2026-09-21--collection-route-filters/plan.md diff --git a/.weaverse/specs/2026-09-21--collection-route-filters/README.md b/.weaverse/specs/2026-09-21--collection-route-filters/README.md new file mode 100644 index 0000000..05e1649 --- /dev/null +++ b/.weaverse/specs/2026-09-21--collection-route-filters/README.md @@ -0,0 +1,29 @@ +# Feature: Collection route filters + +| Field | Value | +| ---------------- | --------------------------------------- | +| **Status** | in-progress | +| **Owner** | @hta218 | +| **Issue** | [#78](https://github.com/Weaverse/forward/issues/78) | +| **Branch** | `feat/collection-route-filters` | +| **Created** | 2026-09-21 | +| **Last Updated** | 2026-09-21 | + +## Original Prompt + +> Check the collection filters for me. Right now only `/shop` has filters; routes of the form `/shop/` do not. Why? +> +> Compare it with Pilot's collection page. +> +> Go with option A, the Pilot model. I need an issue on Forward first — create it with `/create-task`. +> +> Good, now `/work` it. The goal is to finish; when done, commit in multiple commits, one commit per child element. Then push and open a PR. + +## Summary + +`/shop/` handed all rendering to Weaverse and parsed no query +state, so nothing on a collection page held filter or sort. This replaces the +opaque `collection-grid` with a `main-collection` tree — toolbar, content, +filters, product grid — driven by URL query state the route resolves through +the storefront seam. Only Pilot's file organization is borrowed; the facet +model, components and styling are Forward's own. diff --git a/.weaverse/specs/2026-09-21--collection-route-filters/plan.md b/.weaverse/specs/2026-09-21--collection-route-filters/plan.md new file mode 100644 index 0000000..c221e44 --- /dev/null +++ b/.weaverse/specs/2026-09-21--collection-route-filters/plan.md @@ -0,0 +1,89 @@ +# Plan — Collection route filters + +## Problem + +`/shop` (`src/app/shop/page.tsx`) is a hand-written theme-owned page that parses +`category`, `activity` and `sort`, derives filter groups from the catalog, and +renders `FilterSidebar` + `ProductResults`. + +`/shop/[collectionHandle]/page.tsx` renders nothing of its own: it forwards +`searchParams` to `loadWeaversePage()` only and returns ``. The +composed page is `collection-hero` + `system-manifest` + `collection-grid` + +`field-practice`, and `collection-grid` maps the whole product array. No query +state is parsed anywhere on the route, and `getCollectionProducts(handle)` has +no parameter to carry a filter through. + +## Approach + +Adopt Pilot's *organization* — a composable section tree whose filter state +lives in the URL and is resolved by the route — implemented against Forward's +normalized `Product` model. No Pilot source is translated. + +``` +collection-hero (unchanged — already the page header) +main-collection shell: layout + MainCollectionContext +├─ mc--toolbar count, sort control, mobile filter disclosure +└─ mc--content two-column wrapper + ├─ mc--filters facet sidebar + └─ mc--product-grid grid, pagination, empty state +``` + +`collection-grid` is retired; `mc--product-grid` supersedes it and nothing in +the repository references it outside the two registries. + +### Query contract + +The collection route reuses `/shop`'s param names rather than inventing a +second convention: `?category=&activity=&sort=`, plus `?page=` for the grid. +One module owns parsing, facet derivation and href building for both routes. + +### Data boundary + +`getCollectionProducts(handle, filter?, sort?)` gains the same optional +parameters `listProducts` already has, and both adapters run the existing +`filterAndSortProducts` over normalized records, so live mode cannot drift from +static mode. Facets are derived from the collection's *unfiltered* products so +options never disappear mid-filter; counts are computed against the filter. + +### Pagination + +Page-number pagination held in the URL and applied by `mc--product-grid`, whose +`pageSize` is a merchant setting. Slicing is client-side over the collection's +product list, which is already fully loaded in memory by both adapters. Marked +with a `ponytail:` comment naming the ceiling. + +## Files and folders touched + +**New** + +- `src/lib/storefront/catalog-facets.ts` — filter/sort parsing, facet + derivation, href building; shared by `/shop` and the collection route +- `src/sections/main-collection/index.tsx`, `schema.ts`, `context.ts` +- `src/sections/main-collection/toolbar/index.tsx`, `schema.ts` +- `src/sections/main-collection/content/index.tsx`, `schema.ts` +- `src/sections/main-collection/filters/index.tsx`, `schema.ts` +- `src/sections/main-collection/product-grid/index.tsx`, `schema.ts` +- `.weaverse/specs/2026-09-21--collection-route-filters/` + +**Changed** + +- `src/lib/storefront/data-source.ts` — `getCollectionProducts` signature, + static implementation +- `src/lib/storefront/shopify/data-source.ts` — same signature, live path +- `src/lib/weaverse/data-context.tsx` — collection facets, filter, sort, total +- `src/lib/weaverse/components.ts`, `src/lib/weaverse/section-schemas.ts` +- `src/app/shop/[collectionHandle]/page.tsx` — parse query, resolve products +- `src/app/shop/page.tsx` — consume the shared facet module +- `src/components/filter-sidebar.tsx` — types move to the facet module +- `src/sections/product-results.tsx` — follow the type move +- `tests/storefront-data-source.test.ts`, `tests/dom/composed-sections.test.tsx` +- `AGENTS.md` — amend the theme-owned-grid clause + +**Removed** + +- `src/sections/collection-grid/` + +## Verification + +`bun run check` (typecheck, lint, format:check, test, check:graphql, build, +check:theme, check:routes), then `bun run smoke:routes`. From d93f0af5117f7f7c53ce39fda67e8219aa34aa52 Mon Sep 17 00:00:00 2001 From: hta218 Date: Mon, 21 Sep 2026 16:59:07 +0700 Subject: [PATCH 02/49] Resolve catalog facets, filter and sort in one shared module /shop spelled its own param parsing, category list and href builder. Moving them behind a single module gives the collection route the same validated query semantics instead of a second convention, and adds match counts and a facet that is omitted when it cannot change the result. --- src/app/shop/page.tsx | 129 +++----------- src/components/filter-sidebar.tsx | 21 +-- src/lib/storefront/catalog-facets.ts | 258 +++++++++++++++++++++++++++ src/sections/product-results.tsx | 3 +- tests/catalog-facets.test.ts | 174 ++++++++++++++++++ 5 files changed, 464 insertions(+), 121 deletions(-) create mode 100644 src/lib/storefront/catalog-facets.ts create mode 100644 tests/catalog-facets.test.ts diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index 681a959..c011753 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -1,14 +1,15 @@ import type { Metadata } from "next"; import Link from "next/link"; -import type { FilterGroup } from "@/components/filter-sidebar"; import { FilterSidebar } from "@/components/filter-sidebar"; +import { + deriveFilterGroups, + describeFilter, + parseCatalogQuery, + SORT_OPTIONS, + toSearchParams, +} from "@/lib/storefront/catalog-facets"; import { storefront } from "@/lib/storefront/data-source"; -import type { - ProductCategory, - ProductListFilter, - ProductSort, -} from "@/lib/storefront/types"; import { IndexHeader } from "@/sections/index-header"; import { ProductResults } from "@/sections/product-results"; @@ -18,106 +19,21 @@ export const metadata: Metadata = { "The complete Forward catalog: Weatherline Shell, Ridge 30 Field Pack, and Talus Trail Shoe.", }; -const CATEGORY_FILTERS: ReadonlyArray<{ - value: ProductCategory | undefined; - label: string; -}> = [ - { value: undefined, label: "All categories" }, - { value: "shells", label: "Shells" }, - { value: "packs", label: "Packs" }, - { value: "footwear", label: "Footwear" }, -]; - -const SORT_OPTIONS: ReadonlyArray<{ value: ProductSort; label: string }> = [ - { value: "featured", label: "Featured" }, - { value: "price-asc", label: "Price low–high" }, - { value: "price-desc", label: "Price high–low" }, - { value: "name", label: "Name A–Z" }, -]; - -function parseCategory(value: string | undefined): ProductCategory | undefined { - return value === "shells" || value === "packs" || value === "footwear" - ? value - : undefined; -} - -function parseSort(value: string | undefined): ProductSort { - return value === "price-asc" || value === "price-desc" || value === "name" - ? value - : "featured"; -} - -function shopHref( - category: ProductCategory | undefined, - activity: string | undefined, - sort: ProductSort, -): string { - const params = new URLSearchParams(); - if (category !== undefined) { - params.set("category", category); - } - if (activity !== undefined) { - params.set("activity", activity); - } - if (sort !== "featured") { - params.set("sort", sort); - } - const query = params.toString(); - return query.length > 0 ? `/shop?${query}` : "/shop"; -} - interface ShopPageProps { searchParams: Promise>; } export default async function ShopPage({ searchParams }: ShopPageProps) { - const params = await searchParams; - const category = parseCategory( - typeof params.category === "string" ? params.category : undefined, - ); - const sort = parseSort( - typeof params.sort === "string" ? params.sort : undefined, - ); + const params = toSearchParams(await searchParams); const catalog = await storefront.listProducts(); - const activities = [ - ...new Set(catalog.flatMap((product) => product.activities)), - ]; - const requestedActivity = - typeof params.activity === "string" ? params.activity : undefined; - const activity = activities.includes(requestedActivity ?? "") - ? requestedActivity - : undefined; - const filter: ProductListFilter = { category, activity }; + const { filter, sort } = parseCatalogQuery(params, catalog); const products = await storefront.listProducts(filter, sort); - - const filterGroups: readonly FilterGroup[] = [ - { - heading: "Activity", - links: [ - { - key: "all-activities", - label: "All activities", - href: shopHref(category, undefined, sort), - selected: activity === undefined, - }, - ...activities.map((entry) => ({ - key: entry, - label: entry, - href: shopHref(category, entry, sort), - selected: entry === activity, - })), - ], - }, - { - heading: "Category", - links: CATEGORY_FILTERS.map((entry) => ({ - key: entry.label, - label: entry.label, - href: shopHref(entry.value, activity, sort), - selected: entry.value === category, - })), - }, - ]; + const filterGroups = deriveFilterGroups({ + pathname: "/shop", + params, + products: catalog, + filter, + }); return ( <> @@ -136,8 +52,7 @@ export default async function ShopPage({ searchParams }: ShopPageProps) {
{products.length} {products.length === 1 ? "product" : "products"} - {category !== undefined ? ` · ${category}` : ""} - {activity !== undefined ? ` · ${activity}` : ""} + {describeFilter(filter)}
{/* Sorting stays a plain GET form so it works without JavaScript. */} @@ -146,12 +61,12 @@ export default async function ShopPage({ searchParams }: ShopPageProps) { method="get" action="/shop" > - {category !== undefined ? ( - - ) : null} - {activity !== undefined ? ( - - ) : null} + {filter.category === undefined ? null : ( + + )} + {filter.activity === undefined ? null : ( + + )} diff --git a/src/lib/storefront/catalog-facets.ts b/src/lib/storefront/catalog-facets.ts index ae00bb3..b55a088 100644 --- a/src/lib/storefront/catalog-facets.ts +++ b/src/lib/storefront/catalog-facets.ts @@ -35,20 +35,6 @@ export interface FilterGroup { links: readonly FilterLink[]; } -/** - * The category axis: declared order, display label, and the set of valid - * values, in one place. Key order is the order shoppers see — outerwear, then - * carry, then footwear — and `CATEGORIES` is what an unknown param is checked - * against, so the three cannot drift apart. - */ -const CATEGORY_LABELS: Readonly> = { - shells: "Shells", - packs: "Packs", - footwear: "Footwear", -}; - -const CATEGORIES = Object.keys(CATEGORY_LABELS) as readonly ProductCategory[]; - export const SORT_OPTIONS: ReadonlyArray<{ value: ProductSort; label: string; @@ -75,10 +61,11 @@ export function toSearchParams( return params; } -function parseProductCategory( - value: string | null | undefined, -): ProductCategory | undefined { - return CATEGORIES.find((category) => category === value); +/** Categories the page actually offers, in the store's own order. */ +function productCategories( + products: readonly Product[], +): readonly ProductCategory[] { + return [...new Set(products.map((product) => product.category))]; } function parseProductSort(value: string | null | undefined): ProductSort { @@ -88,9 +75,9 @@ function parseProductSort(value: string | null | undefined): ProductSort { } /** - * Parses an activity only when the page actually offers it. + * Parses a value only when the page actually offers it. * - * An unknown activity would otherwise filter every product away and leave the + * An unknown value would otherwise filter every product away and leave the * shopper on an empty grid with no way to tell why. */ function parseProductActivity( @@ -113,7 +100,10 @@ export function parseCatalogQuery( ): { filter: ProductListFilter; sort: ProductSort } { return { filter: { - category: parseProductCategory(params.get("category")), + category: parseProductActivity( + params.get("category"), + productCategories(products), + ), activity: parseProductActivity( params.get("activity"), productActivities(products), @@ -180,7 +170,7 @@ export function deriveFilterGroups({ products, filter, }: FilterGroupOptions): readonly FilterGroup[] { - const present = new Set(products.map((product) => product.category)); + const categories = productCategories(products); const dimensions: readonly FacetDimension[] = [ { param: "activity", @@ -195,8 +185,8 @@ export function deriveFilterGroups({ heading: "Category", resetKey: "all-categories", resetLabel: "All categories", - values: CATEGORIES.filter((category) => present.has(category)), - label: (value) => CATEGORY_LABELS[value as ProductCategory], + values: categories, + label: (value) => value, }, ]; @@ -227,10 +217,7 @@ export function deriveFilterGroups({ /** Human summary of the active narrowing, for a results count line. */ export function describeFilter(filter: ProductListFilter): string { - return [ - filter.category === undefined ? null : CATEGORY_LABELS[filter.category], - filter.activity ?? null, - ] + return [filter.category ?? null, filter.activity ?? null] .filter((entry): entry is string => entry !== null) .map((entry) => ` · ${entry}`) .join(""); diff --git a/src/lib/storefront/product-state.ts b/src/lib/storefront/product-state.ts index 367710c..c33ae39 100644 --- a/src/lib/storefront/product-state.ts +++ b/src/lib/storefront/product-state.ts @@ -4,6 +4,8 @@ * option resolves to one exact purchasable variant. */ +import type { CSSProperties } from "react"; + import type { Money, Product, @@ -182,3 +184,20 @@ export function productColorwayHref( if (first !== undefined && colorwayId === first.id) return base; return `${base}?${COLORWAY_PARAM}=${encodeURIComponent(colorwayId)}`; } + +/** + * How a colorway swatch is painted. + * + * A store that set a native Shopify swatch gets that colour. Most stores set + * none, so the colorway's own product image stands in — which is what the + * shopper is choosing anyway, and needs nothing configured to look right. + */ +export function colorwaySwatchStyle(colorway: ProductColorway): CSSProperties { + return colorway.swatchColor === null + ? { + backgroundImage: `url(${colorway.images.primary.src})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : { backgroundColor: colorway.swatchColor }; +} diff --git a/src/lib/storefront/shopify/mapper.ts b/src/lib/storefront/shopify/mapper.ts index e3ad910..3892abf 100644 --- a/src/lib/storefront/shopify/mapper.ts +++ b/src/lib/storefront/shopify/mapper.ts @@ -5,18 +5,13 @@ * so an unexpected shape must fail loudly here rather than degrade into * plausible-looking output. * - * Ownership split: - * - Shopify owns identity, copy, price, options, media, and the five `forward` - * metafields; - * - `catalog-presentation.ts` owns plate, category, activities, subtitle, - * repair copy, related-handle order, colorway IDs, and swatch colors. + * Every field comes from the store. Category is `productType`, activities are + * the product's tags, the subtitle is its first `forward.highlights` entry, + * colorway ids are derived from the published Color values, and swatch colours + * are Shopify's own when the merchant set them. Nothing here consults a + * theme-side table of approved products. */ -import { - CANONICAL_PRODUCT_HANDLES, - type CatalogPresentationProfile, - getCatalogPresentationProfile, -} from "../catalog-presentation"; import { isShopifyProductImageUrl } from "../image-source"; import type { ColorwayImages, @@ -35,6 +30,31 @@ import { CATALOG_OWNERSHIP_TAG } from "./queries"; /** Shopify option that becomes colorways instead of a normalized option. */ const COLOR_OPTION_NAME = "Color"; +/** How many same-type products a PDP offers as related. */ +const RELATED_PRODUCT_LIMIT = 4; + +/** + * Tags the storefront never shows a shopper: the ownership marker and any + * `namespace:value` bookkeeping tag a seeding or ops tool wrote. + */ +function isInfrastructureTag(tag: string): boolean { + return tag === CATALOG_OWNERSHIP_TAG || tag.includes(":"); +} + +/** + * A colorway id derived from the Color value the store actually publishes. + * + * It is a URL segment (`?colorway=`), so it has to be stable and readable + * without a theme-side table deciding what each label is "really" called. + */ +function colorwayId(label: string): string { + const slug = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "default"; +} + /** The normalized model is USD-only. */ const REQUIRED_CURRENCY_CODE = "USD"; @@ -170,13 +190,11 @@ interface MappedOptions { colorLabels: readonly string[]; /** Every non-Color option, in Shopify order. */ options: readonly ProductOption[]; + /** Native Shopify swatch colour per Color label, `null` when unset. */ + swatches: ReadonlyMap; } -function mapOptions( - value: unknown, - handle: string, - profile: CatalogPresentationProfile, -): MappedOptions { +function mapOptions(value: unknown, handle: string): MappedOptions { const nodes = asArray(value, `${handle} options`); if (nodes.length === 0) { fail(`${handle} has no product options.`); @@ -184,17 +202,20 @@ function mapOptions( let colorLabels: readonly string[] | undefined; const options: ProductOption[] = []; + const swatches = new Map(); for (const [index, node] of nodes.entries()) { const context = `${handle} option ${index}`; const record = asRecord(node, context); const name = asText(record.name, `${context} name`); - const values = asArray(record.optionValues, `${context} optionValues`).map( - (entry, valueIndex) => - asText( - asRecord(entry, `${context} value ${valueIndex}`).name, - `${context} value ${valueIndex} name`, - ), + const valueRecords = asArray( + record.optionValues, + `${context} optionValues`, + ).map((entry, valueIndex) => + asRecord(entry, `${context} value ${valueIndex}`), + ); + const values = valueRecords.map((entry, valueIndex) => + asText(entry.name, `${context} value ${valueIndex} name`), ); if (values.length === 0) { fail(`${context} has no values.`); @@ -208,6 +229,19 @@ function mapOptions( fail(`${handle} has more than one ${COLOR_OPTION_NAME} option.`); } colorLabels = values; + /* Shopify's own swatch when the merchant set one. Most stores have + * none, and the selector falls back to the colorway image. */ + for (const [valueIndex, entry] of valueRecords.entries()) { + const swatch = entry.swatch; + const color = + swatch === null || swatch === undefined + ? null + : asRecord(swatch, `${context} value ${valueIndex} swatch`).color; + swatches.set( + values[valueIndex] as string, + typeof color === "string" && color.length > 0 ? color : null, + ); + } continue; } options.push({ name, values }); @@ -216,33 +250,7 @@ function mapOptions( if (colorLabels === undefined) { fail(`${handle} has no ${COLOR_OPTION_NAME} option.`); } - const expectedColorLabels = Object.keys(profile.colorways); - if (colorLabels.some((label) => !Object.hasOwn(profile.colorways, label))) { - fail(`${handle} has no approved colorway mapping.`); - } - if ( - colorLabels.length !== expectedColorLabels.length || - colorLabels.some((label, index) => label !== expectedColorLabels[index]) - ) { - fail(`${handle} ${COLOR_OPTION_NAME} values are not in canonical order.`); - } - const expectedValues = profile.optionValues; - if (expectedValues === undefined) { - if (options.length !== 0) { - fail(`${handle} has unsupported non-Color product options.`); - } - } else { - const size = options[0]; - if ( - options.length !== 1 || - size?.name !== "Size" || - size.values.length !== expectedValues.length || - size.values.some((entry, index) => entry !== expectedValues[index]) - ) { - fail(`${handle} Size values do not match the canonical option contract.`); - } - } - return { colorLabels, options }; + return { colorLabels, options, swatches }; } /* -------------------------------------------------------------------------- */ @@ -362,51 +370,44 @@ function mapColorways( colorLabels: readonly string[], mediaMap: ReadonlyMap, images: ReadonlyMap, - profile: CatalogPresentationProfile, + handle: string, + swatches: ReadonlyMap, ): readonly ProductColorway[] { - const handle = profile.handle; const seenIds = new Set(); - const presentations = colorLabels.map((label) => { - /* Own-key lookup only: a live Color label such as "constructor" must not - resolve through the prototype chain. */ - const presentation = Object.hasOwn(profile.colorways, label) - ? profile.colorways[label] - : undefined; - if (presentation === undefined) { - fail( - `${handle} ${COLOR_OPTION_NAME} value "${label}" has no approved colorway mapping.`, - ); - } - if (seenIds.has(presentation.id)) { - fail(`${handle} maps more than one colorway to id ${presentation.id}.`); + for (const label of colorLabels) { + const id = colorwayId(label); + if (seenIds.has(id)) { + fail(`${handle} has two ${COLOR_OPTION_NAME} values with the same id.`); } - seenIds.add(presentation.id); - return { label, presentation }; - }); + seenIds.add(id); + } - const usesDisplayLabels = - mediaMap.size === presentations.length && - presentations.every(({ label }) => mediaMap.has(label)); - const usesColorwayIds = - mediaMap.size === presentations.length && - presentations.every(({ presentation }) => mediaMap.has(presentation.id)); + /* The media map may be keyed by Color label or by the derived id; a store + * writes whichever reads better in the metafield editor. */ + const covers = (key: (label: string) => string) => + mediaMap.size === colorLabels.length && + colorLabels.every((label) => mediaMap.has(key(label))); + const usesDisplayLabels = covers((label) => label); + const usesColorwayIds = covers(colorwayId); if (!usesDisplayLabels && !usesColorwayIds) { fail( - `${handle} forward.colorway_media_map must use one complete approved key set: Color display values or colorway ids.`, + `${handle} forward.colorway_media_map must cover every ${COLOR_OPTION_NAME} value, keyed by display value or by colorway id.`, ); } const usedMediaIds = new Set(); - const colorways = presentations.map(({ label, presentation }) => { - const mapKey = usesDisplayLabels ? label : presentation.id; - const ids = mediaMap.get(mapKey); + const colorways = colorLabels.map((label) => { + const id = colorwayId(label); + const ids = mediaMap.get(usesDisplayLabels ? label : id); if (ids === undefined) { - fail(`${handle} forward.colorway_media_map is missing key "${mapKey}".`); + fail(`${handle} forward.colorway_media_map is missing key "${label}".`); } return { - id: presentation.id, + id, name: label, - swatchColor: presentation.swatchColor, + /* Null unless the merchant set a native swatch; the selector then + * falls back to this colorway's own image. */ + swatchColor: swatches.get(label) ?? null, images: buildColorwayImages( ids, images, @@ -416,6 +417,8 @@ function mapColorways( }; }); + /* Media the map never claimed means the product ships images no colorway + * shows, which is a broken map rather than a store with extra photos. */ if (usedMediaIds.size !== images.size) { fail(`${handle} has unreferenced MediaImage nodes.`); } @@ -674,7 +677,6 @@ function mapVariants( handle: string, colorLabels: readonly string[], options: readonly ProductOption[], - profile: CatalogPresentationProfile, ): MappedVariants { const connection = asRecord(value, `${handle} variants`); const pageInfo = asRecord(connection.pageInfo, `${handle} variants pageInfo`); @@ -751,11 +753,6 @@ function mapVariants( ) { fail(`${context} references an unknown ${COLOR_OPTION_NAME} value.`); } - const presentationColorway = profile.colorways[color.value]; - if (presentationColorway === undefined) { - fail(`${context} has no approved colorway mapping.`); - } - const selectedOptions = selectedOptionRecords.slice(1); for (const [optionIndex, selected] of selectedOptions.entries()) { const option = options[optionIndex]; @@ -765,7 +762,7 @@ function mapVariants( } const selectionKey = [ - presentationColorway.id, + colorwayId(color.value), ...selectedOptions.map(({ name, value }) => `${name}:${value}`), ].join("\u001f"); if (selections.has(selectionKey)) { @@ -779,7 +776,7 @@ function mapVariants( } variants.push({ id, - colorwayId: presentationColorway.id, + colorwayId: colorwayId(color.value), selectedOptions, price, compareAtPrice: mapNullableMoney( @@ -793,46 +790,6 @@ function mapVariants( if (minimum === undefined) { fail(`${handle} has no usable variant price.`); } - for (const colorway of Object.values(profile.colorways)) { - if (!variants.some((variant) => variant.colorwayId === colorway.id)) { - fail(`${handle} has no approved colorway mapping for ${colorway.id}.`); - } - } - const expectedVariantCount = - Object.keys(profile.colorways).length * (profile.optionValues?.length ?? 1); - if (variants.length !== expectedVariantCount) { - fail( - `${handle} must expose exactly ${expectedVariantCount} canonical option combinations.`, - ); - } - const combinations = optionCombinations(options); - const expectedVariantOrder = Object.values(profile.colorways).flatMap( - (colorway) => - combinations.map((selectedOptions) => ({ - colorwayId: colorway.id, - selectedOptions, - })), - ); - const orderMismatch = variants.some((variant, index) => { - const expected = expectedVariantOrder[index]; - return ( - expected === undefined || - variant.colorwayId !== expected.colorwayId || - variant.selectedOptions.length !== expected.selectedOptions.length || - variant.selectedOptions.some((selected, optionIndex) => { - const expectedOption = expected.selectedOptions[optionIndex]; - return ( - expectedOption === undefined || - selected.name !== expectedOption.name || - selected.value !== expectedOption.value - ); - }) - ); - }); - if (orderMismatch) { - fail(`${handle} variants are not in canonical option order.`); - } - return { price: minimum, variants }; } @@ -844,13 +801,6 @@ function mapProduct(node: unknown, index: number): Product { const record = asRecord(node, `catalog product ${index}`); const handle = asText(record.handle, `catalog product ${index} handle`); - const profile = getCatalogPresentationProfile(handle); - if (profile === null) { - fail( - `Catalog product "${handle}" is not an approved Forward product in this slice.`, - ); - } - const tags = asArray(record.tags, `${handle} tags`).map((tag, tagIndex) => asText(tag, `${handle} tag ${tagIndex}`), ); @@ -859,7 +809,7 @@ function mapProduct(node: unknown, index: number): Product { } asText(record.id, `${handle} id`); - asText(record.productType, `${handle} productType`); + const productType = asText(record.productType, `${handle} productType`); const title = asText(record.title, `${handle} title`); // Validate both Storefront fields. `descriptionHtml` preserves paragraph // boundaries that Shopify removes from the plain `description` string. @@ -871,13 +821,12 @@ function mapProduct(node: unknown, index: number): Product { fail(`${handle} description has no readable text.`); } - const { colorLabels, options } = mapOptions(record.options, handle, profile); + const { colorLabels, options, swatches } = mapOptions(record.options, handle); const { price, variants } = mapVariants( record.variants, handle, colorLabels, options, - profile, ); const images = mapMediaImages(record.media, handle); @@ -889,9 +838,15 @@ function mapProduct(node: unknown, index: number): Product { ), handle, ); - const colorways = mapColorways(colorLabels, mediaMap, images, profile); + const colorways = mapColorways( + colorLabels, + mediaMap, + images, + handle, + swatches, + ); - validateHighlights( + const highlights = validateHighlights( readMetafieldValue( record.highlights, METAFIELD_TYPES.highlights, @@ -929,19 +884,24 @@ function mapProduct(node: unknown, index: number): Product { return { handle, title, - subtitle: profile.subtitle, - category: profile.category, - activities: profile.activities, + /* The store's own lead highlight. There is no `subtitle` field in the + * Storefront API and inventing a metafield for one every merchant would + * have to fill is worse than using the line they already wrote. */ + subtitle: highlights[0] ?? "", + category: productType, + activities: tags.filter((tag) => !isInfrastructureTag(tag)), price, description: descriptionParagraphs.join(" "), detailParagraphs: [...descriptionParagraphs, ...materialParagraphs], specs, care, - repair: profile.repair, + /* Repair is brand policy, identical for every product, so it is a theme + * setting rather than a field each product would have to repeat. */ + repair: "", colorways, options, variants, - relatedHandles: profile.relatedHandles, + relatedHandles: [], }; } @@ -978,11 +938,19 @@ export function mapCatalogResult( mapped.set(product.handle, product); } - return CANONICAL_PRODUCT_HANDLES.map((handle) => { - const product = mapped.get(handle); - if (product === undefined) { - fail(`The live catalog is missing the approved product "${handle}".`); - } - return product; - }); + /* Related products are the store's other items of the same product type. + * It is a rule over live data rather than a per-handle list the theme keeps, + * so a product added in Shopify is related to its siblings immediately. */ + const catalog = [...mapped.values()]; + return catalog.map((product) => ({ + ...product, + relatedHandles: catalog + .filter( + (entry) => + entry.handle !== product.handle && + entry.category === product.category, + ) + .map((entry) => entry.handle) + .slice(0, RELATED_PRODUCT_LIMIT), + })); } diff --git a/src/lib/storefront/types.ts b/src/lib/storefront/types.ts index 538c562..a21a4fd 100644 --- a/src/lib/storefront/types.ts +++ b/src/lib/storefront/types.ts @@ -31,8 +31,11 @@ export interface ColorwayImages { export interface ProductColorway { id: string; name: string; - /** Solid swatch color rendered by PLP/PDP colorway selectors. */ - swatchColor: string; + /** + * Shopify's own swatch colour when the merchant set one, otherwise `null`. + * Most stores set none, so selectors fall back to the colorway image. + */ + swatchColor: string | null; images: ColorwayImages; } @@ -64,7 +67,11 @@ export interface SpecRow { value: string; } -export type ProductCategory = "shells" | "packs" | "footwear"; +/** + * The store's own product type, verbatim. It is a label the merchant controls, + * not a taxonomy the theme declares, so it is an open string. + */ +export type ProductCategory = string; export interface Product { handle: string; @@ -87,10 +94,15 @@ export interface Product { export interface Collection { handle: string; title: string; - /** Short field-report style code, e.g. "FG-01". */ + /** + * Short field-report style code from the `forward.field_code` metafield. + * Empty when the store sets none; the hero then omits the eyebrow code. + */ fieldCode: string; + /** The store's own description; empty when the merchant wrote none. */ description: string; - heroImage: StorefrontImage; + /** The collection image, or `null` when the store has not set one. */ + heroImage: StorefrontImage | null; productHandles: readonly string[]; } diff --git a/src/sections/main-product/variant-selector/index.tsx b/src/sections/main-product/variant-selector/index.tsx index b7bd435..1367e88 100644 --- a/src/sections/main-product/variant-selector/index.tsx +++ b/src/sections/main-product/variant-selector/index.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { colorwayIsSoldOut, + colorwaySwatchStyle, findExactVariant, productSelectionHref, resolveProductSelection, @@ -111,7 +112,7 @@ function ProductVariantSelector({