diff --git a/.weaverse/specs/2026-09-18--main-product-children/plan.md b/.weaverse/specs/2026-09-18--main-product-children/plan.md index 0c06b3f..50b8192 100644 --- a/.weaverse/specs/2026-09-18--main-product-children/plan.md +++ b/.weaverse/specs/2026-09-18--main-product-children/plan.md @@ -29,11 +29,11 @@ main-product section shell: grid, gallery side, panel width; ## Safeguards -- A `main-product` with no children (the route fallback, or a template seeded - before this change) renders the default composition, so a product URL is - never without gallery, selection and add to cart. -- Every setting has a code default equal to its schema default, because the - route fallback renders without Weaverse. +- Every setting has a code default equal to its schema default. +- (2026-09-27) No default composition: the route no longer renders + `main-product` when the page omits it, and a childless `main-product` + renders only its shell. A Weaverse project always ships its default + templates, and an empty one renders empty. ## Files 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..d1fdd0e --- /dev/null +++ b/.weaverse/specs/2026-09-21--collection-route-filters/README.md @@ -0,0 +1,37 @@ +# Feature: Collection route filters + +| Field | Value | +| ---------------- | --------------------------------------- | +| **Status** | completed | +| **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. + +## Follow-up + +Superseded in the same branch by +[2026-09-22 store-driven catalog](../2026-09-22--store-driven-catalog/README.md): +the Activity and Category facets this spec introduced were theme constants that +existed in no Shopify store, and were replaced by the Storefront API's own +filter connection. + +## 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..69f9143 --- /dev/null +++ b/.weaverse/specs/2026-09-21--collection-route-filters/plan.md @@ -0,0 +1,92 @@ +# 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 +├─ mc--toolbar count, sort control +└─ mc--content two-column wrapper + ├─ mc--filters desktop sidebar, mobile disclosure + └─ 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. +The Studio preset supplies the complete child tree. `main-collection` renders +only its configured children, with no implicit composition for an empty shell. + +### 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. +The `mc--filters` count setting applies to both desktop and mobile facets. + +### 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` +- `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 +- `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`. diff --git a/.weaverse/specs/2026-09-21--collection-route-filters/work-logs.md b/.weaverse/specs/2026-09-21--collection-route-filters/work-logs.md new file mode 100644 index 0000000..1f9feff --- /dev/null +++ b/.weaverse/specs/2026-09-21--collection-route-filters/work-logs.md @@ -0,0 +1,16 @@ +# Work logs + +## 2026-09-21 — PR #79 review fixes + +- Synced `origin/feat/collection-route-filters` before editing. +- Moved the mobile facet disclosure into `mc--filters` so its `showCounts` + setting controls both mobile and desktop. Updated the section settings copy + and DOM coverage. +- Removed the unused `collectionBrowse.total` field from the route, context, + and test fixtures. +- Verified with Bun 1.3.14: frozen install, typecheck, lint, format check, + tests, GraphQL check, build, theme and route checks, route smoke test, and + the aggregate `check` command. +- Removed the empty `main-collection` shell's implicit child composition after + review. Studio's preset supplies the toolbar, filters, and grid; the DOM + composition test now renders that authored tree explicitly. diff --git a/.weaverse/specs/2026-09-22--store-driven-catalog/README.md b/.weaverse/specs/2026-09-22--store-driven-catalog/README.md new file mode 100644 index 0000000..df336e8 --- /dev/null +++ b/.weaverse/specs/2026-09-22--store-driven-catalog/README.md @@ -0,0 +1,30 @@ +# Feature: Store-driven catalog + +| Field | Value | +| ---------------- | --------------------------------------- | +| **Status** | completed | +| **Owner** | @hta218 | +| **Issue** | [#80](https://github.com/Weaverse/forward/issues/80) | +| **Branch** | `feat/collection-route-filters` | +| **Created** | 2026-09-22 | +| **Last Updated** | 2026-09-27 | + +## Original Prompt + +> Next, the filters — where are the facets coming from right now? +> +> I connected Pilot to this store and it only has these two filters +> [Availability, Price]. So Forward's filters are completely hardcoded, right? +> +> Do it now, it belongs in this PR #79. Remove all the hardcoding — how else +> could it run on a different real store? It has to work the way Pilot does. + +## Summary + +Forward's catalog was a fixed set of nine theme-declared products: Shopify +supplied copy, price and images, while the ownership tag, the handle allowlist +and the per-handle presentation profiles decided everything else — including +the Activity and Category facets, which exist in no Shopify store. This makes +the catalog store-driven: facets come from the Storefront API's own filter +connection, and every field previously read from a profile is sourced from the +store or retired. diff --git a/.weaverse/specs/2026-09-22--store-driven-catalog/plan.md b/.weaverse/specs/2026-09-22--store-driven-catalog/plan.md new file mode 100644 index 0000000..99b2490 --- /dev/null +++ b/.weaverse/specs/2026-09-22--store-driven-catalog/plan.md @@ -0,0 +1,95 @@ +# Plan — Store-driven catalog + +## Evidence + +A read-only probe of the connected store (Storefront API 2026-07) decided every +mapping below. Structure only was printed; no prices, tokens or bodies. + +| Probe | Result | +| --- | --- | +| `collection.products.filters` | `filter.v.availability` (LIST), `filter.v.price` (PRICE_RANGE) — on every collection | +| `QueryRoot.products.filters` | empty — all-products has no facets, which is why Pilot's `/products` is sort-only | +| `productType` | `Outerwear`, `Packs`, `Footwear` | +| `tags` | 48 values; `alpine`/`trail`/`camp`/`travel` present, mixed with material and feature tags | +| option value swatches | none — 0 colors, 0 images | +| `forward.*` metafields | `highlights`, `materials`, `field_specs`, `care`, `colorway_media_map` only | +| `productRecommendations` | returns 8 handles | + +## Field sourcing + +| `Product` field | Was | Becomes | +| --- | --- | --- | +| facets | `catalog-presentation.ts` profiles | `collection.products.filters` | +| `category` | `profile.category` (3-literal union) | `productType`, typed `string` | +| `activities` | `profile.activities` | `tags` minus infrastructure tags; display only, never a filter | +| `relatedHandles` | `profile.relatedHandles` | `productRecommendations` | +| `subtitle` | `profile.subtitle` | first `forward.highlights` entry | +| `repair` | `profile.repair` | theme setting — brand policy, not per-product data | +| colorway swatch | `profile.colorways` hex | the colorway's own image; the store has no native swatches | + +## Approach + +Filter state keeps Pilot's contract: `?filter.=` collected into a +`ProductFilter[]` and passed to Shopify as a query variable, so narrowing is +server-side and the facet list is whatever the store exposes. Sort moves to +`ProductCollectionSortKeys`/`ProductSortKeys` with `reverse`. Pagination +becomes cursor-based. + +The whole-catalog read is retired: a collection is its own query, so the cache +key includes handle, filters, sort and cursor rather than one blob. + +`/shop` becomes sort-only and composed through the `ALL_PRODUCTS` page type, +matching Pilot. `mc--filters` renders whatever facets the response carried, so +a merchant enabling a filter in Search & Discovery gets it with no code change. + +## Ordered slices + +1. Break the profile dependency in `mapProduct`; widen `ProductCategory`. Done. +2. Re-author `fixtures/products.ts` as literal data; re-source + `collection-presentation.ts`. Done. +3. Per-collection query with `filters`, `sortKey`, `reverse`, cursors; new + mapper for the filter connection. Done. +4. Route + `mc--filters`/`mc--toolbar`/`mc--product-grid` onto native facets; + retire `catalog-facets.ts`'s invented dimensions. Done. +5. `/shop` to `ALL_PRODUCTS` composition, sort-only. Done. +6. Suites, scripts, seeds and `AGENTS.md`. Done. + +## What the API decided + +Two findings changed the shape of the work after the plan was written. + +`QueryRoot.products` accepts no `filters` argument — only `first`, `after`, +`last`, `before`, `reverse`, `sortKey` and `query`. Faceted browsing is a +collection feature, so `/shop` is sort and paging only. This is why Pilot's +`/products` is sort-only too, and the theme does not offer controls that could +not be applied. + +`TITLE` exists in both `ProductCollectionSortKeys` and `ProductSortKeys`, so +every sort option maps to a real key and none is applied after the fact. + +## Field sourcing, as shipped + +Colorway ids derive from the published Color values, so `?colorway=` changed +(`charcoal` became `charcoal-moss`). Old links still resolve to the product and +fall back to its first colorway. Swatches use Shopify's native option swatch +when the merchant set one, and the colorway's own image otherwise — this store +sets none. `repair` is empty until it becomes a theme setting; the store has no +source for it. + +## Files and folders touched + +**Removed**: `src/lib/storefront/catalog-presentation.ts` + +**Changed**: `src/lib/storefront/shopify/{queries,mapper,client,cache-policy,data-source}.ts`, +`src/lib/storefront/{types,catalog-query,catalog-facets,collection-presentation,data-source}.ts`, +`src/lib/storefront/fixtures/products.ts`, `src/app/shop/page.tsx`, +`src/app/shop/[collectionHandle]/page.tsx`, `src/sections/main-collection/**`, +`src/lib/weaverse/{components,section-schemas,data-context}.tsx?`, +`src/lib/weaverse/settings/`, `AGENTS.md` + +**New**: `src/sections/all-products/**` + +**Suites**: `tests/shopify-catalog-adapter.test.ts`, +`tests/storefront-data-source.test.ts`, `tests/production-polish-home.test.ts`, +`tests/catalog-facets.test.ts`, `tests/dom/collection-browse.test.tsx`, +`tests/browser/home.pw.ts`, `scripts/verify-shopify.mts` diff --git a/.weaverse/specs/2026-09-22--store-driven-catalog/work-logs.md b/.weaverse/specs/2026-09-22--store-driven-catalog/work-logs.md new file mode 100644 index 0000000..d1f8005 --- /dev/null +++ b/.weaverse/specs/2026-09-22--store-driven-catalog/work-logs.md @@ -0,0 +1,45 @@ +# Work Logs + +## 2026-09-27 — @hta218 + +- Removed every fallback that substituted content for what the store or the + Weaverse page did not provide: + - Routes no longer render their own copy of a section a template omits + (`/shop`, PDP); a missing Weaverse page is `notFound()`, an empty one + renders empty; a childless `main-product` renders only its shell, and an + `mp--*` child outside it renders nothing. + - Studio revalidation without a route context is an error, not a bare client. + - Shopify mode no longer falls back to static navigation, footer or + collection structure. Menus map whatever the merchant arranged + (`navigation-mapper.ts`): Shopify paths route onto theme routes, a missing + menu is empty, and off-store or unroutable links are left out. + - The header Shop panel is built from the merchant's Shop links, dressed with + each collection's own description, image and field code. The + `FIELD_INDEX_PRESENTATION` and `COLLECTION_PRESENTATION_PROFILES` tables + are gone; the static profiles now live only in `fixtures/collections.ts`. +- Collection images: the CDN allowlist is scoped to the store tenant, so + `collections/` images pass alongside `files/`. +- Files: `src/lib/weaverse/server.ts`, `src/app/{shop,products}/**`, + `src/sections/main-product/{index,context}.tsx`, + `src/app/api/weaverse/revalidate/route.ts`, + `src/lib/storefront/shopify/{navigation-mapper,data-source}.ts`, + `src/lib/storefront/{data-source,image-source}.ts`, + `src/lib/storefront/fixtures/collections.ts`, + `src/components/site-header/**`, `scripts/verify-shopify.mts`, and their tests. + +## 2026-09-27 — @hta218 (content) + +- Content is store-driven: the query reads every page (`pages`) and every + article across blogs (`articles`, newest first) instead of seven aliased + pages and one approved blog, and `content-presentation.ts` is gone. + - Article plate counts from the oldest, reading time comes from word count, + the image is the article's own, and location/coordinates are the optional + `forward.location` / `forward.coordinates` metafields. + - Pages carry no eyebrow or image (the page hero uses its settings); + unheaded paragraphs form one untitled section. Policies have no summary. + - An entry whose body the parser refuses is left out (its route 404s) + instead of failing the whole content read; the live store's + `data-sharing-opt-out` page is the case that proved it. +- Shopify path → theme route mapping is shared by menus and content links + (`shopify/theme-routes.ts`); `/collections/` maps to `/shop/`. +- Header links carry only `sort` into `/shop/**`: facets are per collection. diff --git a/AGENTS.md b/AGENTS.md index c99f13e..4f56d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,16 @@ Forward is a fresh Next.js App Router storefront theme using Nothing in this repository is a translation of Pilot source. - The existing static Forward POC is a visual reference only; do not copy its implementation wholesale. - Storefront completeness is defined by `.weaverse/specs/2026-08-05--static-demo-productionization/README.md` and the Shopify route contract. +- The catalog is the store's. No theme-side table decides which products are + approved, what category or activities they have, or which facets exist: + `category` is `productType`, `activities` are the product's tags, colorway + ids derive from the published Color values, related products are the store's + other items of the same type, and facets come from the Storefront API's own + filter connection. A facet value's `input` is opaque Shopify JSON that + round-trips through the URL untouched, so a filter a merchant enables in + Search & Discovery works with no code change. Sort options are Shopify sort + keys. Never reintroduce an approved-handle allowlist or a presentation + profile table — both made the theme unable to run on another store. - Build the theme before making deployment or demo-integration decisions. - Routes compose named sections from `src/sections/`; they do not inline section markup. A section is pure presentation: every piece of content and @@ -22,16 +32,23 @@ Forward is a fresh Next.js App Router storefront theme using never import the data source, and a section reused by more than one route takes its variations as props rather than forking into a near-copy. - Functional, stateful, and security-owned surfaces are not sections and stay - theme-owned: the collection and Shop grid behavior, Cart, and - `/account/**`. Header and Footer are theme-owned components configured - through theme settings, never Weaverse global sections. + theme-owned: Cart and `/account/**`. Header and Footer are theme-owned + components configured through theme settings, never Weaverse global sections. +- Approved change (2026-09-22): catalog browsing is composed. A collection is + the `main-collection` tree — `mc--toolbar` and `mc--content`, with + `mc--filters` and `mc--product-grid` under content — and `/shop` is the + `ALL_PRODUCTS` page type with the `all-products` tree (`ap--toolbar`, + `ap--product-grid`). Query state stays theme-owned: the route validates the + facet params, the sort and the cursor, reads one page through + `getCollectionPage`/`getProductsPage`, and hands the result down as `browse`. + A section never parses a param or decides what a filter means, so reordering + the tree cannot change which products a URL selects. `collection-grid`, `catalog-facets.ts`, + `FilterSidebar` and `product-results` are retired. - Approved change (2026-09-18): the PDP buy block is the composable `main-product` section, split into `mp--media`, `mp--info` and one `mp--*` child per element. The section shell alone owns the `colorway`/`size` query state and shares the resolved selection through `MainProductContext`; all - cart logic stays inside the shared `AddToCartForm`. A `main-product` with no - children renders the default composition, so a product URL is never - without gallery, selection and add to cart. + cart logic stays inside the shared `AddToCartForm`. - Approved exception (2026-09-15): `product-spotlight` may embed the shared `AddToCartForm` (`src/components/add-to-cart-form.tsx`) with a selection held in component state. It never reads or writes the PDP's `colorway`/`size` @@ -43,6 +60,10 @@ Forward is a fresh Next.js App Router storefront theme using the module. Sections that are *not* Weaverse components keep named exports. The registry in `src/lib/weaverse/components.ts` is the only list the SDK sees; a component absent from it cannot be composed. +- A Weaverse project always ships its default templates. A page renders + exactly as authored — an empty template renders empty — and no route or + section substitutes a default composition for what the page omits. A route + with no Weaverse page answers `notFound()`. - Theme settings live one group per file under `src/lib/weaverse/settings/`, each declared `as const satisfies WeaverseNextThemeSchemaGroup`. `settings/types.ts` derives `ThemeSettings` from those declarations, so @@ -72,9 +93,15 @@ Forward is a fresh Next.js App Router storefront theme using queries, or raw Shopify shapes directly in pages or components. - Mode selection is explicit and fails closed: no Shopify environment selects the static adapter, a complete environment selects the Shopify adapter, and - a partial environment throws a sanitized configuration error. Product data - never falls back in Shopify mode. Only validated navigation and canonical - collection structure may use their explicit deterministic safeguards. + a partial environment throws a sanitized configuration error. Nothing falls + back to fixtures in Shopify mode — products, collections and menus alike. + Menus are the merchant's as arranged: a menu the store has not set up is + empty, and a link to another origin or to a route the theme lacks is left + out. Failing closed is + about malformed data, not about unfamiliar data: a product the theme has not + seen, a colour it does not recognise, a collection it did not expect and a + facet it has no renderer for are all ordinary, and only a truncated page, a + broken shape or a media map that does not cover its colours is an error. - Server catalog reads use `PRIVATE_STOREFRONT_API_TOKEN` with the Hydrogen `private_no_buyer_context` client. The private token must never reach browser code, props, logs, errors, tests, fixtures, or Git. Environment access stays @@ -146,6 +173,7 @@ Credential-dependent gates are never part of `check`: runs the live build/route/read-only gates for both account-disabled and account-enabled states. - `bun run verify:shopify` is the opt-in live read-only catalog verification. + It asserts rules that hold for any store, never a fixed catalog. - `bun run test:browser` aggregates `test:browser:static`, `test:browser:live-account-disabled`, and `test:browser:live-account-enabled` against fresh production builds. It fails when a required credential matrix diff --git a/scripts/verify-shopify.mts b/scripts/verify-shopify.mts index f564766..5a878b5 100644 --- a/scripts/verify-shopify.mts +++ b/scripts/verify-shopify.mts @@ -20,17 +20,8 @@ import { createShopifyRequestContext, createStorefrontClient, } from "@shopify/hydrogen"; -import { - CANONICAL_PRODUCT_HANDLES, - getCatalogPresentationProfile, -} from "../src/lib/storefront/catalog-presentation.ts"; import { createStorefrontDataSource } from "../src/lib/storefront/data-source.ts"; import { isShopifyProductImageUrl } from "../src/lib/storefront/image-source.ts"; -import { - CONTENT_ARTICLE_HANDLES, - CONTENT_PAGE_HANDLES, - CONTENT_POLICY_HANDLES, -} from "../src/lib/storefront/shopify/content-query.ts"; import { readShopifyCatalogConfig } from "../src/lib/storefront/shopify/env.ts"; import { safeErrorLabel } from "../src/lib/storefront/shopify/errors.ts"; import { SHOP_IDENTITY_QUERY } from "../src/lib/storefront/shopify/queries.ts"; @@ -50,7 +41,7 @@ const CANONICAL_COLLECTION_HANDLES = [ const CANONICAL_VARIANT_COUNT = 78; const CANONICAL_SHOP_LINKS = [ - "/shop", + "/shop/forward", "/shop/outerwear", "/shop/packs", "/shop/footwear", @@ -69,7 +60,7 @@ const CANONICAL_FOOTER_COLUMNS = [ { heading: "Shop", links: [ - { href: "/shop", label: "All products" }, + { href: "/shop/forward", label: "All products" }, { href: "/shop/outerwear", label: "Outerwear" }, { href: "/shop/packs", label: "Packs" }, { href: "/shop/footwear", label: "Footwear" }, @@ -96,6 +87,33 @@ const CANONICAL_FOOTER_COLUMNS = [ }, ] as const; +/** The Forward demo store's published content; the theme reads any store's. */ +const CANONICAL_PAGE_HANDLES = [ + "about-forward", + "field-repair", + "shipping-returns", + "contact", + "materials-and-care", + "fit-and-sizing", + "field-testing", +] as const; + +const CANONICAL_ARTICLE_HANDLES = [ + "layering-for-moving-weather", + "packing-thirty-liters-for-a-long-day", + "reading-the-trail-underfoot", + "how-we-test-a-shell-before-calling-it-weatherproof", + "a-two-day-kit-built-around-nine-kilograms", + "repair-notes-what-five-years-of-use-should-look-like", +] as const; + +const CANONICAL_POLICY_HANDLES = [ + "privacy-policy", + "refund-policy", + "shipping-policy", + "terms-of-service", +] as const; + const MEDIA_ROLES = ["primary", "alternate", "detail", "context"] as const; const failures: string[] = []; @@ -201,47 +219,31 @@ await probeShopIdentity( try { // This CLI runs outside the Next runtime. Exercise the exact Hydrogen // transport/mapping seam while leaving the production default Data Cache on. - let collectionFallbackUsed = false; - let footerFallbackUsed = false; - let navigationFallbackUsed = false; const config = readShopifyCatalogConfig(process.env); if (config === null) { throw new Error("Shopify catalog mode is not configured."); } const storefront = createStorefrontDataSource(process.env, { useNextCache: false, - onCollectionFallback: () => { - collectionFallbackUsed = true; - }, - onFooterFallback: () => { - footerFallbackUsed = true; - }, - onNavigationFallback: () => { - navigationFallbackUsed = true; - }, }); const navigation = await storefront.getNavigation(); - const shop = navigation.primary.find((item) => item.href === "/shop"); + const shop = navigation.primary.find((item) => item.href === "/shop/forward"); const about = navigation.primary.find( (item) => item.href === "/pages/about-forward", ); check( "live main-menu has the canonical two-level tree", - !navigationFallbackUsed && - navigation.primary.map((item) => item.href).join(",") === - "/shop,/journal,/pages/about-forward,/search" && + navigation.primary.map((item) => item.href).join(",") === + "/shop/forward,/journal,/pages/about-forward,/search" && shop?.children?.map((item) => item.href).join(",") === CANONICAL_SHOP_LINKS.join(",") && about?.children?.map((item) => item.href).join(",") === CANONICAL_ABOUT_LINKS.join(","), - navigationFallbackUsed - ? "static safeguard active" - : `${shop?.children?.length ?? 0} Shop children, ${about?.children?.length ?? 0} About children`, + `${shop?.children?.length ?? 0} Shop children, ${about?.children?.length ?? 0} About children`, ); check( "live footer has the canonical three-column tree", - !footerFallbackUsed && - navigation.footerColumns.length === CANONICAL_FOOTER_COLUMNS.length && + navigation.footerColumns.length === CANONICAL_FOOTER_COLUMNS.length && navigation.footerColumns.every( (column, columnIndex) => column.heading === CANONICAL_FOOTER_COLUMNS[columnIndex]?.heading && @@ -255,56 +257,20 @@ try { CANONICAL_FOOTER_COLUMNS[columnIndex]?.links[linkIndex]?.label, ), ), - footerFallbackUsed - ? "static safeguard active" - : `${navigation.footerColumns.length} live Footer columns`, + `${navigation.footerColumns.length} live Footer columns`, ); const products = await storefront.listProducts(); check( - "adapter returns the canonical catalog in order", - products.length === CANONICAL_PRODUCT_HANDLES.length && - products.every( - (product, index) => product.handle === CANONICAL_PRODUCT_HANDLES[index], - ), + "adapter returns a non-empty catalog with unique handles", + products.length > 0 && + new Set(products.map((product) => product.handle)).size === + products.length, products.map((product) => product.handle).join(", "), ); for (const product of products) { - const profile = getCatalogPresentationProfile(product.handle); - const expectedOptionValues = profile?.optionValues; - const optionsMatch = - expectedOptionValues === undefined - ? product.options.length === 0 - : product.options.length === 1 && - product.options[0]?.name === "Size" && - JSON.stringify(product.options[0].values) === - JSON.stringify(expectedOptionValues); - const optionSelections = - expectedOptionValues === undefined - ? [[]] - : expectedOptionValues.map((value) => [{ name: "Size", value }]); - const expectedVariants = - profile === null - ? [] - : Object.values(profile.colorways).flatMap((colorway) => - optionSelections.map((selectedOptions) => ({ - colorwayId: colorway.id, - selectedOptions, - })), - ); - const variantsMatchOrder = - product.variants.length === expectedVariants.length && - product.variants.every((variant, index) => { - const expected = expectedVariants[index]; - return ( - expected !== undefined && - variant.colorwayId === expected.colorwayId && - JSON.stringify(variant.selectedOptions) === - JSON.stringify(expected.selectedOptions) - ); - }); check( `${product.handle} money`, product.price.currencyCode === "USD" && @@ -314,26 +280,35 @@ try { ); check( - `${product.handle} options`, - profile !== null && optionsMatch, - product.options.length === 0 - ? "no non-Color options" - : product.options - .map((option) => `${option.name} x${option.values.length}`) - .join(", "), + `${product.handle} presentation from the store`, + product.category.length > 0 && product.activities.length > 0, + `type "${product.category}", ${product.activities.length} tags`, ); + + /* Every published Color value must resolve to exactly one colorway, and + * every variant must point at one of them. */ + const colorwayIds = new Set(product.colorways.map((entry) => entry.id)); check( - `${product.handle} variant order`, - profile !== null && variantsMatchOrder, - `${product.variants.length} canonical combinations in order`, + `${product.handle} colorways`, + colorwayIds.size === product.colorways.length && + product.variants.every((variant) => + colorwayIds.has(variant.colorwayId), + ), + product.colorways.map((entry) => entry.name).join(", "), ); - const colorwayIds = product.colorways.map((colorway) => colorway.id); + const optionValueCount = product.options.reduce( + (total, option) => total * option.values.length, + 1, + ); check( - `${product.handle} colorways`, - new Set(colorwayIds).size === colorwayIds.length && - colorwayIds.length > 0, - colorwayIds.join(", "), + `${product.handle} variant matrix`, + product.variants.length === product.colorways.length * optionValueCount, + product.options.length === 0 + ? "no non-Color options" + : product.options + .map((option) => `${option.name} x${option.values.length}`) + .join(", "), ); const mediaOk = product.colorways.every((colorway) => @@ -375,12 +350,9 @@ try { ); const collections = await storefront.listCollections(); - const canonicalCollectionsInOrder = - collections.length === CANONICAL_COLLECTION_HANDLES.length && - collections.every( - (collection, index) => - collection.handle === CANONICAL_COLLECTION_HANDLES[index], - ); + const publishedHandles = new Set( + collections.map((collection) => collection.handle), + ); for (const handle of CANONICAL_COLLECTION_HANDLES) { const collectionProducts = await storefront.getCollectionProducts(handle); @@ -394,16 +366,16 @@ try { check( "unknown handles resolve to null", (await storefront.getProduct("__forward-missing__")) === null && - (await storefront.getCollectionProducts("frontpage")) === null, + (await storefront.getCollectionProducts("__forward-missing__")) === null, "no invented catalog records", ); check( - "canonical collection reads stayed live and in contract order", - !collectionFallbackUsed && canonicalCollectionsInOrder, - collectionFallbackUsed - ? "static safeguard active" - : collections.map((collection) => collection.handle).join(", "), + "every canonical collection is published", + CANONICAL_COLLECTION_HANDLES.every((handle) => + publishedHandles.has(handle), + ), + collections.map((collection) => collection.handle).join(", "), ); const emptySearch = await storefront.searchProducts(" "); @@ -417,18 +389,16 @@ try { const pages = await storefront.listPages(); const articles = await storefront.listArticles(); + const pageHandles = new Set(pages.map((page) => page.handle)); + const articleHandles = new Set(articles.map((article) => article.handle)); check( - "approved live page handles", - pages.length === CONTENT_PAGE_HANDLES.length && - pages.every((page, index) => page.handle === CONTENT_PAGE_HANDLES[index]), + "every canonical page is published", + CANONICAL_PAGE_HANDLES.every((handle) => pageHandles.has(handle)), pages.map((page) => `${page.handle}:${page.title}`).join(", "), ); check( - "approved live article handles", - articles.length === CONTENT_ARTICLE_HANDLES.length && - articles.every( - (article, index) => article.handle === CONTENT_ARTICLE_HANDLES[index], - ), + "every canonical article is published", + CANONICAL_ARTICLE_HANDLES.every((handle) => articleHandles.has(handle)), articles.map((article) => `${article.handle}:${article.title}`).join(", "), ); check( @@ -439,11 +409,10 @@ try { const policies = await storefront.listPolicies(); check( - "approved live policy handles", - policies.length === CONTENT_POLICY_HANDLES.length && - policies.every( - (policy, index) => policy.handle === CONTENT_POLICY_HANDLES[index], - ), + "every canonical policy is published", + CANONICAL_POLICY_HANDLES.every((handle) => + policies.some((policy) => policy.handle === handle), + ), policies.map((policy) => `${policy.handle}:${policy.title}`).join(", "), ); check( diff --git a/scripts/weaverse-seed/template-all-products.json b/scripts/weaverse-seed/template-all-products.json new file mode 100644 index 0000000..9577673 --- /dev/null +++ b/scripts/weaverse-seed/template-all-products.json @@ -0,0 +1,10 @@ +{ + "pageType": "ALL_PRODUCTS", + "handle": "", + "description": "All products template.", + "sections": [ + { + "type": "all-products" + } + ] +} diff --git a/scripts/weaverse-seed/template-collection.json b/scripts/weaverse-seed/template-collection.json index aa43a0e..6c5ad5f 100644 --- a/scripts/weaverse-seed/template-collection.json +++ b/scripts/weaverse-seed/template-collection.json @@ -3,7 +3,9 @@ "handle": "", "description": "Collection template.", "sections": [ - { "type": "collection-hero" }, + { + "type": "collection-hero" + }, { "type": "system-manifest", "data": { @@ -15,7 +17,11 @@ } } }, - { "type": "collection-grid" }, - { "type": "field-practice" } + { + "type": "main-collection" + }, + { + "type": "field-practice" + } ] } diff --git a/src/app/api/weaverse/revalidate/route.ts b/src/app/api/weaverse/revalidate/route.ts index fbfe5fc..6b3004e 100644 --- a/src/app/api/weaverse/revalidate/route.ts +++ b/src/app/api/weaverse/revalidate/route.ts @@ -24,6 +24,9 @@ export const { POST } = createWeaverseNextRevalidateHandler({ _request: Request, requestContext?: WeaverseNextRequestContext, ) => { + if (requestContext === undefined) { + throw new Error("Studio sent no valid route context to revalidate."); + } const client = await revalidateServerClient(requestContext); if (client === null) { throw new Error("Weaverse is not configured for this deployment."); diff --git a/src/app/policies/[policyHandle]/page.tsx b/src/app/policies/[policyHandle]/page.tsx index 7ce3c5c..2fa75b0 100644 --- a/src/app/policies/[policyHandle]/page.tsx +++ b/src/app/policies/[policyHandle]/page.tsx @@ -25,7 +25,10 @@ export async function generateMetadata({ if (policy === null) { return { title: "Policy not found" }; } - return { title: policy.title, description: policy.summary }; + return { + title: policy.title, + ...(policy.summary === "" ? {} : { description: policy.summary }), + }; } export default async function PolicyPage({ params }: PolicyPageProps) { diff --git a/src/app/products/[productHandle]/page.tsx b/src/app/products/[productHandle]/page.tsx index 356a05e..ddaae68 100644 --- a/src/app/products/[productHandle]/page.tsx +++ b/src/app/products/[productHandle]/page.tsx @@ -3,15 +3,12 @@ import { notFound } from "next/navigation"; import { storefront } from "@/lib/storefront/data-source"; import type { Product } from "@/lib/storefront/types"; -import { StorefrontDataProvider } from "@/lib/weaverse/data-context"; import { WeaversePage } from "@/lib/weaverse/page"; -import { pageRenders } from "@/lib/weaverse/page-payload"; import { loadWeaversePage, type SearchParams, weaverseProjectId, } from "@/lib/weaverse/server"; -import MainProduct from "@/sections/main-product"; interface ProductPageProps { params: Promise<{ productHandle: string }>; @@ -69,23 +66,10 @@ export default async function ProductPage(props: ProductPageProps) { const related = await relatedProducts(product); return ( - <> - {/* The buy block is the one surface a product URL cannot be without. It - * is a section so Studio can compose and configure it, but a template - * that has not been seeded — or one a merchant removed it from — must - * not leave a product page with no gallery, no variant selection and no - * way to add to cart. So the route renders it, childless and therefore - * in its default composition, when the page does not. */} - {pageRenders(page, "main-product") ? null : ( - - - - )} - - + ); } diff --git a/src/app/shop/[collectionHandle]/page.tsx b/src/app/shop/[collectionHandle]/page.tsx index 35c977b..3f86c68 100644 --- a/src/app/shop/[collectionHandle]/page.tsx +++ b/src/app/shop/[collectionHandle]/page.tsx @@ -1,7 +1,14 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; - import { storefront } from "@/lib/storefront/data-source"; +import { + AFTER_PARAM, + BEFORE_PARAM, + parseFilterParams, + SORT_PARAM, + toSearchParams, +} from "@/lib/storefront/filter-params"; +import { parseProductSort } from "@/lib/storefront/sort"; import { WeaversePage } from "@/lib/weaverse/page"; import { loadWeaversePage, @@ -47,24 +54,42 @@ export async function generateMetadata({ * it renders is the route's decision, not a merchant's. The resource travels * to the sections through `dataContext`; the template only decides layout and * copy. + * + * Filter and sort are the route's decision too. They are query state — shared, + * bookmarked, and untrusted until validated — and they select which products + * are read, so they are resolved here and handed down already narrowed. The + * `main-collection` tree below is presentation over that result. */ export default async function CollectionPage(props: CollectionPageProps) { const { collectionHandle } = await props.params; - const [collection, products, page, projectId] = await Promise.all([ + const searchParams = await props.searchParams; + const params = toSearchParams(searchParams); + const pathname = `/shop/${collectionHandle}`; + const sort = parseProductSort(params.get(SORT_PARAM)); + + const [collection, page, weaversePage, projectId] = await Promise.all([ storefront.getCollection(collectionHandle), - storefront.getCollectionProducts(collectionHandle), + /* The store narrows, orders and pages. Facet shapes travel from the URL + * into the query untouched, so a filter the merchant enabled after this + * code shipped still works. */ + storefront.getCollectionPage(collectionHandle, { + filters: parseFilterParams(params), + sort, + after: params.get(AFTER_PARAM) ?? undefined, + before: params.get(BEFORE_PARAM) ?? undefined, + }), loadWeaversePage({ handle: collectionHandle, - pathname: `/shop/${collectionHandle}`, - searchParams: await props.searchParams, + pathname, + searchParams, type: "COLLECTION", }), Promise.resolve(weaverseProjectId()), ]); if ( collection === null || - products === null || page === null || + weaversePage === null || projectId === null ) { notFound(); @@ -72,8 +97,12 @@ export default async function CollectionPage(props: CollectionPageProps) { return ( ); diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index 681a959..39274cb 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -1,188 +1,69 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { notFound } from "next/navigation"; -import type { FilterGroup } from "@/components/filter-sidebar"; -import { FilterSidebar } from "@/components/filter-sidebar"; 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"; +import { + AFTER_PARAM, + BEFORE_PARAM, + SORT_PARAM, + toSearchParams, +} from "@/lib/storefront/filter-params"; +import { parseProductSort } from "@/lib/storefront/sort"; +import { WeaversePage } from "@/lib/weaverse/page"; +import { + loadWeaversePage, + type SearchParams, + weaverseProjectId, +} from "@/lib/weaverse/server"; export const metadata: Metadata = { title: "Shop", - description: - "The complete Forward catalog: Weatherline Shell, Ridge 30 Field Pack, and Talus Trail Shoe.", + description: "Every product this store publishes.", }; -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>; + 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 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 products = await storefront.listProducts(filter, sort); +/** + * Shop — the whole catalog. + * + * Order and paging are query state the route validates before any product is + * read; the composed `all-products` tree below is presentation over the page + * the store returned. The Storefront API accepts no filters outside a + * collection, so this route is sort and paging only — faceted browsing lives + * on `/shop/`. + */ +export default async function ShopPage(props: ShopPageProps) { + const searchParams = await props.searchParams; + const params = toSearchParams(searchParams); + const sort = parseProductSort(params.get(SORT_PARAM)); - 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 [page, weaversePage, projectId] = await Promise.all([ + storefront.getProductsPage({ + sort, + after: params.get(AFTER_PARAM) ?? undefined, + before: params.get(BEFORE_PARAM) ?? undefined, + }), + loadWeaversePage({ + pathname: "/shop", + searchParams, + type: "ALL_PRODUCTS", + }), + Promise.resolve(weaverseProjectId()), + ]); + if (weaversePage === null || projectId === null) { + notFound(); + } return ( - <> - - Home / Shop - - } - eyebrowLabel="Explore / All equipment" - heading="Field goods for moving outside." - lede="A compact system of weather protection, carry, and footwear. Designed to work hard together and age well apart." - /> - -
-
- - {products.length} {products.length === 1 ? "product" : "products"} - {category !== undefined ? ` · ${category}` : ""} - {activity !== undefined ? ` · ${activity}` : ""} - -
- {/* Sorting stays a plain GET form so it works without JavaScript. */} -
- {category !== undefined ? ( - - ) : null} - {activity !== undefined ? ( - - ) : null} - - - -
-
- -
- - -
- + ); } diff --git a/src/components/catalog-grid.tsx b/src/components/catalog-grid.tsx new file mode 100644 index 0000000..4729f09 --- /dev/null +++ b/src/components/catalog-grid.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { cva } from "class-variance-authority"; +import { usePathname, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; + +import { CursorPagination } from "@/components/cursor-pagination"; +import { ProductCard } from "@/components/product-card"; +import { cn } from "@/lib/cn"; +import type { CollectionProductsPage, Product } from "@/lib/storefront/types"; +import { + elementAttributes, + type WeaverseElementProps, +} from "@/sections/weaverse-element"; + +export type CatalogColumns = "2" | "3" | "4"; + +const grid = cva( + "grid grid-cols-2 gap-x-2.5 gap-y-8.75 sm:gap-x-4.5 sm:gap-y-14", + { + variants: { + columns: { "2": "", "3": "lg:grid-cols-3", "4": "lg:grid-cols-4" }, + }, + defaultVariants: { columns: "3" }, + }, +); + +/** Full-bleed placements put the grid and its paging in the page container. */ +const PAGE_CONTAINER = "mx-auto w-full max-w-page px-page-gutter"; + +interface CatalogGridProps extends WeaverseElementProps { + products: readonly Product[]; + pageInfo?: CollectionProductsPage["pageInfo"]; + columns?: CatalogColumns; + /** Whether the grid sits in the page container or in a row that has one. */ + contained?: boolean; + className?: string; + /** What shows when the store returned no products for this page. */ + empty: ReactNode; +} + +/** A page of products the store returned, with cursor paging beneath it. */ +export function CatalogGrid({ + products, + pageInfo, + columns, + contained, + className, + empty, + ...rest +}: CatalogGridProps) { + const pathname = usePathname(); + const params = useSearchParams(); + + return ( +
+

Products

+ {products.length === 0 ? ( + empty + ) : ( + <> +
+ {products.map((product, index) => ( + + ))} +
+ {pageInfo === undefined ? null : ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/src/components/catalog-toolbar.tsx b/src/components/catalog-toolbar.tsx new file mode 100644 index 0000000..4143bfc --- /dev/null +++ b/src/components/catalog-toolbar.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; + +import { SortForm } from "@/components/sort-form"; +import { cn } from "@/lib/cn"; +import { + clearFiltersHref, + hasAppliedFilters, +} from "@/lib/storefront/filter-params"; +import type { ProductSort } from "@/lib/storefront/types"; +import { + elementAttributes, + type WeaverseElementProps, +} from "@/sections/weaverse-element"; + +export interface CatalogToolbarSettings { + showCount?: boolean; + showSort?: boolean; + sticky?: boolean; +} + +interface CatalogToolbarProps + extends CatalogToolbarSettings, + WeaverseElementProps { + count: number; + sort: ProductSort; + sortId: string; + /** Offer "Clear filters" when facets are applied; a collection has facets. */ + clearable?: boolean; +} + +/** + * The bar above a catalog grid: how many products this page shows, a way to + * drop every applied facet, and the order control. + * + * The count is the page, not the collection: with cursor paging the total is + * a separate question the store was not asked, and claiming one would be a + * guess. + */ +export function CatalogToolbar({ + count, + sort, + sortId, + clearable, + showCount, + showSort, + sticky, + ...rest +}: CatalogToolbarProps) { + const pathname = usePathname(); + const params = useSearchParams(); + + return ( +
+
+ {showCount === false ? null : ( + + {count} {count === 1 ? "product" : "products"} + + )} + {clearable && hasAppliedFilters(params) ? ( + + Clear filters + + ) : null} +
+ {showSort === false ? null : ( + + )} +
+ ); +} diff --git a/src/components/cursor-pagination.tsx b/src/components/cursor-pagination.tsx new file mode 100644 index 0000000..2d06842 --- /dev/null +++ b/src/components/cursor-pagination.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; + +import { pageHref } from "@/lib/storefront/filter-params"; +import type { CollectionProductsPage } from "@/lib/storefront/types"; + +const link = + "flex min-h-touch items-center justify-center border border-ink px-4.5 font-body text-micro font-bold tracking-label uppercase hover:bg-surface-subtle"; + +/** + * Cursor paging, as links. + * + * There is no page number because there is no page count: a cursor names a + * position in a result the store is streaming, not an index into a list the + * theme holds. Claiming "page 3 of 7" would mean a second query for a total + * nobody asked for. + */ +export function CursorPagination({ + pageInfo, + pathname, + params, +}: { + pageInfo: CollectionProductsPage["pageInfo"]; + pathname: string; + params: URLSearchParams; +}) { + if (!pageInfo.hasPreviousPage && !pageInfo.hasNextPage) { + return null; + } + return ( + + ); +} diff --git a/src/components/facet-list.tsx b/src/components/facet-list.tsx new file mode 100644 index 0000000..cfa25ce --- /dev/null +++ b/src/components/facet-list.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Link from "next/link"; + +import { cn } from "@/lib/cn"; +import { + AFTER_PARAM, + BEFORE_PARAM, + isValueApplied, + PRICE_MAX_PARAM, + PRICE_MIN_PARAM, + parsePriceRange, + toggleValueHref, +} from "@/lib/storefront/filter-params"; +import type { StorefrontFilter } from "@/lib/storefront/types"; + +interface FacetListProps { + filters: readonly StorefrontFilter[]; + pathname: string; + params: URLSearchParams; + idPrefix: string; + showCounts?: boolean; +} + +/** + * The store's facets, rendered from whatever it returned. + * + * A `LIST` facet is a set of links; a `PRICE_RANGE` facet is a plain GET form + * over the same href contract. Neither needs JavaScript, and a facet type the + * theme has no renderer for is skipped rather than guessed at. + */ +export function FacetList({ + filters, + pathname, + params, + idPrefix, + showCounts = true, +}: FacetListProps) { + return ( +
+ {filters.map((filter) => ( +
+ + {filter.label} + +
+ {filter.type === "PRICE_RANGE" ? ( + + ) : filter.type === "LIST" || filter.type === "BOOLEAN" ? ( + filter.values.map((value) => { + const applied = isValueApplied(params, filter, value); + return ( + +
+
+ ))} +
+ ); +} + +/** + * A price range as a GET form. + * + * The bounds the store reported are the input placeholders, so the shopper + * sees the range that actually exists before typing one. + */ +function PriceRange({ + filter, + pathname, + params, + idPrefix, +}: { + filter: StorefrontFilter; + pathname: string; + params: URLSearchParams; + idPrefix: string; +}) { + const bounds = (() => { + try { + const parsed = JSON.parse(filter.values[0]?.input ?? "{}") as { + price?: { min?: number; max?: number }; + }; + return parsed.price ?? {}; + } catch { + return {}; + } + })(); + const applied = parsePriceRange(params) ?? {}; + /* Everything the form does not own travels as a hidden input, so the active + facets and the chosen order survive a submit. */ + const preserved = [...params.entries()].filter( + ([key]) => + key !== PRICE_MIN_PARAM && + key !== PRICE_MAX_PARAM && + key !== AFTER_PARAM && + key !== BEFORE_PARAM, + ); + + return ( +
+ {preserved.map(([key, value]) => ( + + ))} +
+ + + to + + +
+ +
+ ); +} diff --git a/src/components/filter-sidebar.tsx b/src/components/filter-sidebar.tsx deleted file mode 100644 index 65d9389..0000000 --- a/src/components/filter-sidebar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import Link from "next/link"; - -import { cn } from "@/lib/cn"; - -export interface FilterLink { - key: string; - label: string; - href: string; - selected: boolean; -} - -export interface FilterGroup { - heading: string; - links: readonly FilterLink[]; -} - -/** Each filter row links to validated query state without requiring JavaScript. */ -export function FilterSidebar({ - groups, - idPrefix, -}: { - groups: readonly FilterGroup[]; - idPrefix: string; -}) { - return ( -
- {groups.map((group) => ( -
- - {group.heading} - -
- {group.links.map((link) => ( - -
-
- ))} -
- ); -} diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index b6ca853..82f0c70 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -6,6 +6,7 @@ import { useId, useState } from "react"; import { formatMoney } from "@/lib/storefront/format"; import { + colorwaySwatchStyle, productColorwayHref, resolveColorway, } from "@/lib/storefront/product-state"; @@ -86,7 +87,7 @@ export function ProductCard({ product, priority }: ProductCardProps) { > diff --git a/src/components/site-header/field-index-header.tsx b/src/components/site-header/field-index-header.tsx index 2c32ab7..5e7424f 100644 --- a/src/components/site-header/field-index-header.tsx +++ b/src/components/site-header/field-index-header.tsx @@ -7,7 +7,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { Icon, type IconName } from "@/components/icon"; import { Wordmark } from "@/components/wordmark"; import { cn } from "@/lib/cn"; -import type { NavItem } from "@/lib/storefront/types"; +import type { Collection, NavItem } from "@/lib/storefront/types"; import { AboutIndexPanel } from "./about-index-panel"; import { CartCount } from "./cart-count"; import { CountryControl } from "./country-control"; @@ -17,6 +17,7 @@ import { activeCollectionIndex, createHeaderNavigationHref, fieldIndexCollections, + findShopItem, isActive, isBranchActive, } from "./header-navigation"; @@ -39,6 +40,8 @@ const SCROLL_THRESHOLD = 8; export interface FieldIndexHeaderProps { announcement: string; + /** The store's collections, which dress the Shop panel's rows. */ + collections: readonly Collection[]; primary: readonly NavItem[]; queryString?: string; utility: readonly NavItem[]; @@ -46,16 +49,21 @@ export interface FieldIndexHeaderProps { export function FieldIndexHeader({ announcement, + collections: storeCollections, primary, queryString = "", utility, }: FieldIndexHeaderProps) { const pathname = usePathname(); - const shopItem = primary.find((item) => item.href === "/shop"); + const shopItem = findShopItem(primary); + /* The other branch with links under it opens the index panel. */ const aboutItem = primary.find( - (item) => item.href === "/pages/about-forward", + (item) => + item !== shopItem && + item.href !== "/search" && + (item.children?.length ?? 0) > 0, ); - const collections = fieldIndexCollections(shopItem); + const collections = fieldIndexCollections(shopItem, storeCollections); const aboutHasPanel = (aboutItem?.children?.length ?? 0) > 0; const [desktopOpen, setDesktopOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); @@ -77,7 +85,7 @@ export function FieldIndexHeader({ const searchItem = primary.find((item) => item.href === "/search"); const primaryLinks = primary.filter( - (item) => item.href !== "/shop" && item.href !== "/search", + (item) => item !== shopItem && item.href !== "/search", ); const utilityLinks = utility.filter((item) => item.href !== "/cart"); const accountAvailable = utilityLinks.some( @@ -134,9 +142,12 @@ export function FieldIndexHeader({ setAboutOpen(false); setMobileOpen(false); setActiveIndex( - activeCollectionIndex(pathname, fieldIndexCollections(shopItem) ?? []), + activeCollectionIndex( + pathname, + fieldIndexCollections(shopItem, storeCollections) ?? [], + ), ); - }, [pathname, shopItem]); + }, [pathname, shopItem, storeCollections]); useEffect(() => { if (!desktopOpen && !aboutOpen) { diff --git a/src/components/site-header/field-index-panel.tsx b/src/components/site-header/field-index-panel.tsx index 4dbe0f6..0b8a1d1 100644 --- a/src/components/site-header/field-index-panel.tsx +++ b/src/components/site-header/field-index-panel.tsx @@ -51,10 +51,7 @@ export function FieldIndexPanel({ {String(collections.length).padStart(2, "0")} systems
-
diff --git a/src/components/site-header/header-navigation.ts b/src/components/site-header/header-navigation.ts index 20d0f12..d80f4d3 100644 --- a/src/components/site-header/header-navigation.ts +++ b/src/components/site-header/header-navigation.ts @@ -1,18 +1,17 @@ -import { - type CanonicalCollectionHandle, - COLLECTION_PRESENTATION_PROFILES, -} from "@/lib/storefront/collection-presentation"; -import type { NavItem, StorefrontImage } from "@/lib/storefront/types"; +import type { + Collection, + NavItem, + StorefrontImage, +} from "@/lib/storefront/types"; export interface FieldIndexCollection { - id: CanonicalCollectionHandle; - index: "00" | "01" | "02" | "03"; + id: string; + index: string; label: string; href: string; - coordinate: string; + fieldCode: string; description: string; - fieldNote: string; - image: StorefrontImage; + image: StorefrontImage | null; } /** @@ -27,7 +26,9 @@ const DESTINATION_OWNED_PARAMS: readonly { params: readonly string[]; }[] = [ { prefix: "/search", params: ["q"] }, - { prefix: "/shop", params: ["category", "activity", "sort"] }, + /* Order carries across collections; facets do not — each collection offers + * its own, so a facet value from one names nothing in the next. */ + { prefix: "/shop", params: ["sort"] }, ]; function ownedParams(path: string): readonly string[] { @@ -64,98 +65,6 @@ export function createHeaderNavigationHref( return `${path}${query === "" ? "" : `?${query}`}${hash}`; } -interface FieldIndexPresentation { - id: FieldIndexCollection["id"]; - index: FieldIndexCollection["index"]; - href: string; - coordinate: string; - description: string; - fieldNote: string; - image: StorefrontImage; -} - -function collectionImage(handle: FieldIndexCollection["id"]): StorefrontImage { - const profile = COLLECTION_PRESENTATION_PROFILES.find( - (entry) => entry.handle === handle, - ); - if (profile === undefined) { - throw new Error(`Missing collection presentation for ${handle}.`); - } - return profile.heroImage; -} - -/** Static visual metadata; Shopify owns labels, order, hierarchy, and URLs. */ -export const FIELD_INDEX_PRESENTATION = [ - { - id: "forward", - index: "00", - href: "/shop", - coordinate: "54.4609° N / 03.0886° W", - description: - "The complete Forward catalog, ordered as one continuous field index.", - fieldNote: "Every system in one list, from shell layers to trail footwear.", - image: collectionImage("forward"), - }, - { - id: "outerwear", - index: "01", - href: "/shop/outerwear", - coordinate: "54.4609° N", - description: - "Weatherproof layers built for exposed ground and changing forecasts.", - fieldNote: "Protection designed for movement, repair, and repeat use.", - image: collectionImage("outerwear"), - }, - { - id: "packs", - index: "02", - href: "/shop/packs", - coordinate: "03.0886° W", - description: - "Low-bulk carry systems composed for long miles above the tree line.", - fieldNote: "Stable load transfer for distance, exposure, and movement.", - image: collectionImage("packs"), - }, - { - id: "footwear", - index: "03", - href: "/shop/footwear", - coordinate: "ALT. 978 M", - description: - "Dependable trail footwear tuned for grip, feedback, and long days out.", - fieldNote: "Ground contact selected for utility, not excess.", - image: collectionImage("footwear"), - }, -] as const satisfies readonly FieldIndexPresentation[]; - -/** Maps the exact Shopify `Shop` children into Header 01 presentation cards. */ -export function createFieldIndexCollections( - shopItem: NavItem, -): readonly FieldIndexCollection[] { - const children = shopItem.children ?? []; - if (children.length !== FIELD_INDEX_PRESENTATION.length) { - throw new Error("Header 01 requires Shop to have exactly four children."); - } - return FIELD_INDEX_PRESENTATION.map((presentation, index) => { - const item = children[index]; - if (item === undefined || item.href !== presentation.href) { - throw new Error( - `Header 01 Shop child ${index + 1} must target ${presentation.href}.`, - ); - } - if (item.children !== undefined && item.children.length > 0) { - throw new Error( - "Header 01 collection links cannot contain grandchildren.", - ); - } - return { - ...presentation, - label: item.label, - href: item.href, - }; - }); -} - /** True when `href` names the current page or one of its nested routes. */ export function isActive(pathname: string, href: string): boolean { if (pathname === href || pathname.startsWith(`${href}/`)) { @@ -198,21 +107,44 @@ export function activeCollectionIndex( return Math.max(currentCollectionIndex(pathname, collections), 0); } +/** True for the catalog and any collection route. */ +function isCatalogHref(href: string): boolean { + return href === "/shop" || href.startsWith("/shop/"); +} + +/** The top-level menu entry that opens the Shop panel: the catalog branch. */ +export function findShopItem(primary: readonly NavItem[]): NavItem | undefined { + return primary.find((item) => isCatalogHref(item.href)); +} + /** - * The Shop mega panel is an enhancement on merchant-owned navigation: drifted - * or missing data yields no panel instead of a failed render. + * The Shop panel's rows: the merchant's Shop links, in the merchant's order, + * each dressed with its collection's own description, image and field code. + * A link to a collection the store does not publish keeps its label and loses + * the rest. No Shop links means no panel. */ export function fieldIndexCollections( shopItem: NavItem | undefined, + collections: readonly Collection[], ): readonly FieldIndexCollection[] | null { - if (shopItem === undefined) { - return null; - } - try { - return createFieldIndexCollections(shopItem); - } catch { + const children = shopItem?.children ?? []; + if (children.length === 0) { return null; } + return children.map((item, index) => { + const collection = collections.find( + (entry) => item.href === `/shop/${entry.handle}`, + ); + return { + id: item.href, + index: String(index).padStart(2, "0"), + label: item.label, + href: item.href, + fieldCode: collection?.fieldCode ?? "", + description: collection?.description ?? "", + image: collection?.heroImage ?? null, + }; + }); } /** Account entry reports session state; every other destination keeps its label. */ diff --git a/src/components/site-header/mobile-menu.tsx b/src/components/site-header/mobile-menu.tsx index 2e04d5a..6840d7e 100644 --- a/src/components/site-header/mobile-menu.tsx +++ b/src/components/site-header/mobile-menu.tsx @@ -13,6 +13,7 @@ import { createHeaderNavigationHref, currentCollectionIndex, type FieldIndexCollection, + findShopItem, isActive, } from "./header-navigation"; import { HEADER_CONTROL_CLASS } from "./header-styles"; @@ -98,7 +99,7 @@ export function MobileMenu({ const closeButtonRef = useRef(null); const links = [ ...primary - .filter((item) => item.href !== "/shop" || collections === null) + .filter((item) => collections === null || item !== findShopItem(primary)) .flatMap((item) => [ { item, child: false }, ...(item.children ?? []).map((child) => ({ item: child, child: true })), diff --git a/src/components/site-header/site-header.tsx b/src/components/site-header/site-header.tsx index 75ce8a6..bfa306a 100644 --- a/src/components/site-header/site-header.tsx +++ b/src/components/site-header/site-header.tsx @@ -10,9 +10,10 @@ import { QueryPreservingFieldIndexHeader } from "./query-preserving-field-index- * reader's Suspense fallback so static pages never bail out to client rendering. */ export async function SiteHeader() { - const [navigation, themeContent] = await Promise.all([ + const [navigation, themeContent, collections] = await Promise.all([ storefront.getNavigation(), storefront.getThemeContent(), + storefront.listCollections(), ]); const accountEnabled = getCustomerAccountRuntime() !== null; @@ -25,6 +26,7 @@ export async function SiteHeader() { fallback={ @@ -32,6 +34,7 @@ export async function SiteHeader() { > diff --git a/src/components/sort-form.tsx b/src/components/sort-form.tsx new file mode 100644 index 0000000..ef81f41 --- /dev/null +++ b/src/components/sort-form.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { SORT_OPTIONS } from "@/lib/storefront/sort"; +import type { ProductSort } from "@/lib/storefront/types"; + +/** + * Ordering as a plain GET form, so it works with no JavaScript. + * + * Every param the form does not own travels as a hidden input: the applied + * facets have to survive a re-sort, and so does anything else on the URL. The + * cursor deliberately does not — a new order invalidates it. + */ +export function SortForm({ + sort, + pathname, + params, + id, +}: { + sort: ProductSort; + pathname: string; + params: URLSearchParams; + id: string; +}) { + const preserved = [...params.entries()].filter( + ([key]) => key !== "sort" && key !== "after" && key !== "before", + ); + + return ( +
+ {preserved.map(([key, value]) => ( + + ))} + + + +
+ ); +} diff --git a/src/lib/presentation/variants.ts b/src/lib/presentation/variants.ts index cdcada7..aeb06d7 100644 --- a/src/lib/presentation/variants.ts +++ b/src/lib/presentation/variants.ts @@ -148,3 +148,11 @@ export const blockSpacing = cva("", { }, }, }); + +/** The bottom rhythm of a catalog browse block (`main-collection`, `all-products`). */ +export const browseShell = cva("w-full", { + variants: { + spacing: { compact: "pb-14", standard: "pb-25", roomy: "pb-37.5" }, + }, + defaultVariants: { spacing: "standard" }, +}); diff --git a/src/lib/storefront/catalog-presentation.ts b/src/lib/storefront/catalog-presentation.ts deleted file mode 100644 index 75de04e..0000000 --- a/src/lib/storefront/catalog-presentation.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** Theme-owned presentation fields keyed by the exact managed catalog handles. */ - -import type { ProductCategory } from "./types"; - -export interface PresentationColorway { - id: string; - swatchColor: string; -} - -export interface CatalogPresentationProfile { - handle: string; - category: ProductCategory; - /** Exact non-Color Size values; absent means Color is the only option. */ - optionValues?: readonly string[]; - activities: readonly string[]; - subtitle: string; - repair: string; - relatedHandles: readonly string[]; - colorways: Readonly>; -} - -const CHARCOAL = "#3b403f"; -const MOSS = "#59654b"; -const CLAYSTONE = "#a9705a"; -const DUNE = "#c4ad83"; -const LIMESTONE = "#cfc8b8"; - -export const APPAREL_SIZE_VALUES = ["XS", "S", "M", "L", "XL"] as const; -export const FOOTWEAR_SIZE_VALUES = [ - "US 7", - "US 8", - "US 9", - "US 10", - "US 11", - "US 12", - "US 13", -] as const; - -export const CANONICAL_PRODUCT_HANDLES = [ - "weatherline-shell", - "traverse-grid-fleece", - "drift-insulated-vest", - "ridge-30-field-pack", - "approach-18-day-pack", - "waypoint-sling-6", - "talus-trail-shoe", - "scree-approach-shoe", - "camp-recovery-clog", -] as const; - -export const CATALOG_PRESENTATION_PROFILES: readonly CatalogPresentationProfile[] = - [ - { - handle: "weatherline-shell", - category: "shells", - optionValues: APPAREL_SIZE_VALUES, - activities: ["alpine", "trail", "camp"], - subtitle: "Three-layer waterproof shell for shifting coastal weather.", - repair: - "Field-repair small punctures with tenacious tape; our repairs program handles delaminations, zip replacements, and re-taping for the life of the garment.", - relatedHandles: ["traverse-grid-fleece", "ridge-30-field-pack"], - colorways: { - "Charcoal / Moss": { id: "charcoal", swatchColor: CHARCOAL }, - "Claystone / Charcoal": { id: "claystone", swatchColor: CLAYSTONE }, - }, - }, - { - handle: "traverse-grid-fleece", - category: "shells", - optionValues: APPAREL_SIZE_VALUES, - activities: ["trail", "alpine", "travel"], - subtitle: "Breathable grid fleece for fast temperature changes.", - repair: - "Cuffs, pocket openings, and small tears can be reinforced through the Forward repair program.", - relatedHandles: ["weatherline-shell", "drift-insulated-vest"], - colorways: { - "Moss / Charcoal": { id: "moss-charcoal", swatchColor: MOSS }, - "Claystone / Bone": { id: "claystone-bone", swatchColor: CLAYSTONE }, - }, - }, - { - handle: "drift-insulated-vest", - category: "shells", - optionValues: APPAREL_SIZE_VALUES, - activities: ["alpine", "camp", "travel"], - subtitle: "Packable synthetic warmth for exposed starts and stops.", - repair: - "Shell tears, pocket damage, and insulation migration are assessed and repaired where construction allows.", - relatedHandles: ["traverse-grid-fleece", "weatherline-shell"], - colorways: { - "Charcoal / Signal": { id: "charcoal-signal", swatchColor: CHARCOAL }, - "Dune / Moss": { id: "dune-moss", swatchColor: DUNE }, - }, - }, - { - handle: "ridge-30-field-pack", - category: "packs", - activities: ["alpine", "trail"], - subtitle: "A 30-liter pack built for long days above the treeline.", - repair: - "Buckles, straps, and stays are standard parts we stock for replacement. Torn panels and blown seams go through the repairs program rather than the landfill.", - relatedHandles: ["approach-18-day-pack", "weatherline-shell"], - colorways: { - "Charcoal / Moss / Tan": { id: "charcoal", swatchColor: CHARCOAL }, - "Dune / Charcoal": { id: "dune", swatchColor: DUNE }, - }, - }, - { - handle: "approach-18-day-pack", - category: "packs", - activities: ["alpine", "trail", "travel"], - subtitle: "Close-body 18-liter carry for short technical days.", - repair: - "Replaceable hardware and repairable seams keep the pack in service after hard use.", - relatedHandles: ["ridge-30-field-pack", "scree-approach-shoe"], - colorways: { - "Moss / Charcoal": { id: "moss-charcoal", swatchColor: MOSS }, - "Claystone / Dune": { id: "claystone-dune", swatchColor: CLAYSTONE }, - }, - }, - { - handle: "waypoint-sling-6", - category: "packs", - activities: ["travel", "camp"], - subtitle: "Compact six-liter carry with fast, one-handed access.", - repair: - "Straps, buckles, zip pulls, and accessible seams can be replaced or reinforced.", - relatedHandles: ["approach-18-day-pack", "camp-recovery-clog"], - colorways: { - "Charcoal / Signal": { id: "charcoal-signal", swatchColor: CHARCOAL }, - "Dune / Moss": { id: "dune-moss", swatchColor: DUNE }, - }, - }, - { - handle: "talus-trail-shoe", - category: "footwear", - optionValues: FOOTWEAR_SIZE_VALUES, - activities: ["trail", "camp"], - subtitle: "Grippy, stable footwear for scree, roots, and river rock.", - repair: - "Delaminated outsoles within reason are re-bonded through the repairs program. Worn laces and insoles are standard replaceable parts.", - relatedHandles: ["scree-approach-shoe", "ridge-30-field-pack"], - colorways: { - "Charcoal / Moss / Gum": { id: "charcoal", swatchColor: CHARCOAL }, - "Limestone / Clay / Moss": { id: "limestone", swatchColor: LIMESTONE }, - }, - }, - { - handle: "scree-approach-shoe", - category: "footwear", - optionValues: FOOTWEAR_SIZE_VALUES, - activities: ["alpine", "trail"], - subtitle: "Precise low-profile footwear for rock and mixed approaches.", - repair: - "Laces and footbeds are replaceable; repairable bonding failures are assessed by the repair desk.", - relatedHandles: ["talus-trail-shoe", "approach-18-day-pack"], - colorways: { - "Charcoal / Gum": { id: "charcoal-gum", swatchColor: CHARCOAL }, - "Limestone / Moss": { id: "limestone-moss", swatchColor: LIMESTONE }, - }, - }, - { - handle: "camp-recovery-clog", - category: "footwear", - optionValues: FOOTWEAR_SIZE_VALUES, - activities: ["camp", "travel"], - subtitle: "Easy-on recovery footwear with durable wet-ground traction.", - repair: - "Replaceable straps and repairable bonding failures are handled through the repair desk.", - relatedHandles: ["talus-trail-shoe", "waypoint-sling-6"], - colorways: { - "Charcoal / Moss": { id: "charcoal-moss", swatchColor: CHARCOAL }, - "Dune / Claystone": { id: "dune-claystone", swatchColor: DUNE }, - }, - }, - ]; - -const PROFILES_BY_HANDLE = new Map( - CATALOG_PRESENTATION_PROFILES.map((profile) => [profile.handle, profile]), -); - -export function getCatalogPresentationProfile( - handle: string, -): CatalogPresentationProfile | null { - return PROFILES_BY_HANDLE.get(handle) ?? null; -} diff --git a/src/lib/storefront/catalog-query.ts b/src/lib/storefront/catalog-query.ts index 86e8fc8..85e4d22 100644 --- a/src/lib/storefront/catalog-query.ts +++ b/src/lib/storefront/catalog-query.ts @@ -1,5 +1,5 @@ /** - * Normalized catalog query semantics: filtering, sorting, and search. + * Normalized catalog query semantics: sorting and search. * * Both the static fixture data source and the Shopify-backed data source run * these exact functions over already-normalized `Product` records, so live mode @@ -12,7 +12,7 @@ * - the raw query is never interpolated into Shopify GraphQL search syntax. */ -import type { Product, ProductListFilter, ProductSort } from "./types"; +import type { Product, ProductSort } from "./types"; export function sortProducts( products: readonly Product[], @@ -36,22 +36,6 @@ export function sortProducts( return sorted; } -export function matchesFilter( - product: Product, - filter: ProductListFilter, -): boolean { - if (filter.category !== undefined && product.category !== filter.category) { - return false; - } - if ( - filter.activity !== undefined && - !product.activities.includes(filter.activity) - ) { - return false; - } - return true; -} - function searchableText(product: Product): string { return [ product.title, @@ -65,18 +49,6 @@ function searchableText(product: Product): string { .toLowerCase(); } -/** Applies the normalized filter then the normalized sort, in that order. */ -export function filterAndSortProducts( - products: readonly Product[], - filter: ProductListFilter, - sort: ProductSort, -): readonly Product[] { - return sortProducts( - products.filter((product) => matchesFilter(product, filter)), - sort, - ); -} - /** Deterministic local product search over normalized records. */ export function searchNormalizedProducts( products: readonly Product[], diff --git a/src/lib/storefront/collection-presentation.ts b/src/lib/storefront/collection-presentation.ts deleted file mode 100644 index 22fc6c2..0000000 --- a/src/lib/storefront/collection-presentation.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { CANONICAL_PRODUCT_HANDLES } from "./catalog-presentation"; -import type { StorefrontImage } from "./types"; - -export type CanonicalCollectionHandle = - | "forward" - | "outerwear" - | "packs" - | "footwear"; - -export interface CollectionPresentationProfile { - handle: CanonicalCollectionHandle; - title: string; - fieldCode: string; - description: string; - heroImage: StorefrontImage; - productHandles: readonly string[]; -} - -export const COLLECTION_PRESENTATION_PROFILES = [ - { - handle: "forward", - title: "Forward", - fieldCode: "FW-00", - description: - "The complete Forward catalog: weather protection, modular carry, and trail footwear selected to work as one compact field system.", - heroImage: { - src: "/images/editorial/trail-movement.webp", - alt: "Runner moving along a high dirt trail at golden hour", - width: 2000, - height: 1333, - }, - productHandles: CANONICAL_PRODUCT_HANDLES, - }, - { - handle: "outerwear", - title: "Outerwear", - fieldCode: "OW-01", - description: - "Weatherproof layers built for exposed ground, changing forecasts, and repeat repair across long days outside.", - heroImage: { - src: "/images/editorial/alpine-traverse.webp", - alt: "Hiker traversing an alpine ridgeline above the clouds", - width: 1800, - height: 1201, - }, - productHandles: [ - "weatherline-shell", - "traverse-grid-fleece", - "drift-insulated-vest", - ], - }, - { - handle: "packs", - title: "Packs", - fieldCode: "PK-02", - description: - "Low-bulk carry systems composed for stable movement, deliberate organization, and long miles above the tree line.", - heroImage: { - src: "/images/editorial/camp-tent.webp", - alt: "Tent pitched at dusk beneath a mountain skyline", - width: 2000, - height: 1334, - }, - productHandles: [ - "ridge-30-field-pack", - "approach-18-day-pack", - "waypoint-sling-6", - ], - }, - { - handle: "footwear", - title: "Footwear", - fieldCode: "FT-03", - description: - "Dependable trail footwear tuned for grip, ground feedback, and sustained comfort across changing terrain.", - heroImage: { - src: "/images/editorial/trail-movement.webp", - alt: "Runner moving along a high dirt trail at golden hour", - width: 2000, - height: 1333, - }, - productHandles: [ - "talus-trail-shoe", - "scree-approach-shoe", - "camp-recovery-clog", - ], - }, -] as const satisfies readonly CollectionPresentationProfile[]; - -export function getCollectionPresentationProfile( - handle: string, -): CollectionPresentationProfile | undefined { - return COLLECTION_PRESENTATION_PROFILES.find( - (profile) => profile.handle === handle, - ); -} diff --git a/src/lib/storefront/content-presentation.ts b/src/lib/storefront/content-presentation.ts deleted file mode 100644 index f145486..0000000 --- a/src/lib/storefront/content-presentation.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { EDITORIAL_IMAGES } from "./fixtures/editorial-images"; -import type { StorefrontImage } from "./types"; - -export interface ArticlePresentationProfile { - plate: string; - readingMinutes: number; - location: string; - coordinates: string; - heroImage: StorefrontImage; -} - -export interface PagePresentationProfile { - eyebrow: string; - heroImage?: StorefrontImage; - sectionHeadings: readonly string[]; -} - -export interface PolicyPresentationProfile { - summary: string; -} - -export const ARTICLE_PRESENTATION_PROFILES = { - "layering-for-moving-weather": { - plate: "No. 01", - readingMinutes: 7, - location: "Pacific Crest Trail, California", - coordinates: "36.5785° N, 118.2923° W", - heroImage: EDITORIAL_IMAGES.mountainRidges, - }, - "packing-thirty-liters-for-a-long-day": { - plate: "No. 02", - readingMinutes: 5, - location: "North Cascades, Washington", - coordinates: "48.7718° N, 121.2985° W", - heroImage: EDITORIAL_IMAGES.alpineTraverse, - }, - "reading-the-trail-underfoot": { - plate: "No. 03", - readingMinutes: 6, - location: "Cairngorms, Scotland", - coordinates: "57.0776° N, 3.6710° W", - heroImage: EDITORIAL_IMAGES.campTent, - }, - "how-we-test-a-shell-before-calling-it-weatherproof": { - plate: "No. 04", - readingMinutes: 8, - location: "Lake District, England", - coordinates: "54.4609° N, 3.0886° W", - heroImage: EDITORIAL_IMAGES.campfire, - }, - "a-two-day-kit-built-around-nine-kilograms": { - plate: "No. 05", - readingMinutes: 9, - location: "Snowdonia, Wales", - coordinates: "53.0685° N, 4.0763° W", - heroImage: EDITORIAL_IMAGES.trailMovement, - }, - "repair-notes-what-five-years-of-use-should-look-like": { - plate: "No. 06", - readingMinutes: 8, - location: "Forward Repair Desk", - coordinates: "54.4609° N, 3.0886° W", - heroImage: EDITORIAL_IMAGES.alpineTraverse, - }, -} as const satisfies Record; - -export const PAGE_PRESENTATION_PROFILES = { - "about-forward": { - eyebrow: "About Forward", - heroImage: EDITORIAL_IMAGES.mountainRidges, - sectionHeadings: ["The standard"], - }, - "field-repair": { - eyebrow: "Repairs", - heroImage: EDITORIAL_IMAGES.alpineTraverse, - sectionHeadings: ["A repairable standard"], - }, - "shipping-returns": { - eyebrow: "Shipping & Returns", - heroImage: EDITORIAL_IMAGES.campTent, - sectionHeadings: ["Before you send it back"], - }, - contact: { - eyebrow: "Contact", - heroImage: EDITORIAL_IMAGES.trailMovement, - sectionHeadings: [], - }, - "materials-and-care": { - eyebrow: "Materials & Care", - heroImage: EDITORIAL_IMAGES.campfire, - sectionHeadings: ["Face fabrics and membranes", "Washing and reproofing"], - }, - "fit-and-sizing": { - eyebrow: "Fit & Sizing", - heroImage: EDITORIAL_IMAGES.trailMovement, - sectionHeadings: ["Apparel fit", "Footwear sizing"], - }, - "field-testing": { - eyebrow: "Field Testing", - heroImage: EDITORIAL_IMAGES.alpineTraverse, - sectionHeadings: ["What we test", "What ends a test"], - }, -} as const satisfies Record; - -export const POLICY_PRESENTATION_PROFILES = { - "privacy-policy": { - summary: "How we handle your personal information.", - }, - "refund-policy": { - summary: "Returns, exchanges, and warranty.", - }, - "shipping-policy": { - summary: "Shipping destinations, timelines, and costs.", - }, - "terms-of-service": { - summary: "Terms governing your use of this store.", - }, -} as const satisfies Record; - -export function getArticlePresentationProfile( - handle: string, -): ArticlePresentationProfile | null { - return ( - ARTICLE_PRESENTATION_PROFILES[ - handle as keyof typeof ARTICLE_PRESENTATION_PROFILES - ] ?? null - ); -} - -export function getPagePresentationProfile( - handle: string, -): PagePresentationProfile | null { - return ( - PAGE_PRESENTATION_PROFILES[ - handle as keyof typeof PAGE_PRESENTATION_PROFILES - ] ?? null - ); -} - -export function getPolicyPresentationProfile( - handle: string, -): PolicyPresentationProfile | null { - return ( - POLICY_PRESENTATION_PROFILES[ - handle as keyof typeof POLICY_PRESENTATION_PROFILES - ] ?? null - ); -} diff --git a/src/lib/storefront/data-source.ts b/src/lib/storefront/data-source.ts index 978bf23..eba12b8 100644 --- a/src/lib/storefront/data-source.ts +++ b/src/lib/storefront/data-source.ts @@ -19,10 +19,7 @@ * rather than inventing content. */ -import { - filterAndSortProducts, - searchNormalizedProducts, -} from "./catalog-query"; +import { searchNormalizedProducts, sortProducts } from "./catalog-query"; import { DEMO_CART_SEED } from "./fixtures/account"; import { COLLECTION_FIXTURES } from "./fixtures/collections"; import { JOURNAL_FIXTURES } from "./fixtures/journal"; @@ -33,37 +30,60 @@ import { import { PAGE_FIXTURES } from "./fixtures/pages"; import { POLICY_FIXTURES } from "./fixtures/policies"; import { PRODUCT_FIXTURES } from "./fixtures/products"; +import { + applyProductFilters, + synthesizeProductFilters, +} from "./product-filters"; import { type CatalogQueryExecutorOptions, + createAllProductsQueryExecutor, createCatalogQueryExecutor, + createCollectionQueryExecutor, createNavigationQueryExecutor, } from "./shopify/client"; +import { COLLECTION_PAGE_SIZE } from "./shopify/collection-query"; import { createContentQueryExecutor } from "./shopify/content-client"; import { ShopifyCatalogDataSource } from "./shopify/data-source"; import { type EnvSource, readShopifyCatalogConfig } from "./shopify/env"; -import type { ShopifyCatalogError } from "./shopify/errors"; import type { Collection, + CollectionProductsPage, + CollectionProductsQuery, DemoCartSeedLine, JournalArticle, Policy, Product, - ProductListFilter, - ProductSort, SiteNavigation, StorePage, ThemeContent, } from "./types"; export interface StorefrontDataSource { - listProducts( - filter?: ProductListFilter, - sort?: ProductSort, - ): Promise; + listProducts(): Promise; getProduct(handle: string): Promise; listCollections(): Promise; getCollection(handle: string): Promise; getCollectionProducts(handle: string): Promise; + /** + * One page of a collection, with the facets the store offers for it. + * + * This is the read a browsing route makes: the shopper's filters, order and + * cursor go in, and the products plus the store's own facet list come back. + * `null` means no such collection. + */ + getCollectionPage( + handle: string, + query?: CollectionProductsQuery, + ): Promise; + /** + * One page of the whole catalog, in the same shape a collection page has. + * + * Most stores expose no facets at the catalog level, so this is ordinarily + * sort and paging only — but whatever the store does return is carried. + */ + getProductsPage( + query?: CollectionProductsQuery, + ): Promise; searchProducts(query: string): Promise; listArticles(): Promise; getArticle(handle: string): Promise; @@ -76,20 +96,58 @@ export interface StorefrontDataSource { getDemoCartSeed(): Promise; } -export interface StorefrontDataSourceOptions - extends CatalogQueryExecutorOptions { - onNavigationFallback?: (error: ShopifyCatalogError) => void; - onFooterFallback?: (error: ShopifyCatalogError) => void; - onCollectionFallback?: (error: ShopifyCatalogError) => void; +export type StorefrontDataSourceOptions = CatalogQueryExecutorOptions; + +function decodeCursor(cursor: string | undefined): number | null { + if (cursor === undefined) { + return null; + } + const index = Number.parseInt(cursor, 10); + return Number.isInteger(index) && index >= 0 ? index : null; +} + +/** + * One page of an already-resolved product list, shaped like a live response. + * + * The static data source answers a route exactly as a live read would. + */ +export function localCollectionPage( + all: readonly Product[], + query: CollectionProductsQuery, +): CollectionProductsPage { + const filters = synthesizeProductFilters(all); + const narrowed = sortProducts( + applyProductFilters(all, query.filters ?? []), + query.sort ?? "featured", + ); + const pageBy = query.pageBy ?? COLLECTION_PAGE_SIZE; + const after = decodeCursor(query.after); + const before = decodeCursor(query.before); + const start = + after !== null + ? after + 1 + : before !== null + ? Math.max(0, before - pageBy) + : 0; + const products = narrowed.slice(start, start + pageBy); + + return { + products, + filters, + pageInfo: { + hasPreviousPage: start > 0, + hasNextPage: start + products.length < narrowed.length, + startCursor: products.length > 0 ? String(start) : null, + endCursor: + products.length > 0 ? String(start + products.length - 1) : null, + }, + }; } /** Fixture-backed implementation; the no-credential default. */ export class StaticStorefrontDataSource implements StorefrontDataSource { - async listProducts( - filter: ProductListFilter = {}, - sort: ProductSort = "featured", - ): Promise { - return filterAndSortProducts(PRODUCT_FIXTURES, filter, sort); + async listProducts(): Promise { + return PRODUCT_FIXTURES; } async getProduct(handle: string): Promise { @@ -124,6 +182,35 @@ export class StaticStorefrontDataSource implements StorefrontDataSource { return products.filter((product): product is Product => product !== null); } + /** + * The same page a live collection read would return, computed locally. + * + * Cursors are the index of the last item on the page, which is opaque to + * callers exactly as a Shopify cursor is. + */ + async getCollectionPage( + handle: string, + query: CollectionProductsQuery = {}, + ): Promise { + const all = await this.getCollectionProducts(handle); + if (all === null) { + return null; + } + return localCollectionPage(all, query); + } + + async getProductsPage( + query: CollectionProductsQuery = {}, + ): Promise { + /* The Storefront API accepts no filters outside a collection, so neither + * does this: both modes agree the catalog level is sort and paging only. */ + const page = localCollectionPage(PRODUCT_FIXTURES, { + ...query, + filters: [], + }); + return { ...page, filters: [] }; + } + async searchProducts(query: string): Promise { return searchNormalizedProducts(PRODUCT_FIXTURES, query); } @@ -186,13 +273,12 @@ export function createStorefrontDataSource( return new ShopifyCatalogDataSource({ base, execute: createCatalogQueryExecutor(config, options), + executeCollection: createCollectionQueryExecutor(config, options), + executeAllProducts: createAllProductsQueryExecutor(config, options), executeContent: createContentQueryExecutor(config, options), executeNavigation: createNavigationQueryExecutor(config, options), storeDomain: config.storeDomain, mainMenuHandle: config.mainMenuHandle, - onCollectionFallback: options.onCollectionFallback, - onFooterFallback: options.onFooterFallback, - onNavigationFallback: options.onNavigationFallback, useProcessCache: !useNextCache, }); } diff --git a/src/lib/storefront/filter-params.ts b/src/lib/storefront/filter-params.ts new file mode 100644 index 0000000..6ad64bc --- /dev/null +++ b/src/lib/storefront/filter-params.ts @@ -0,0 +1,198 @@ +/** + * The browse URL: which facet values are applied, in what order, on which page. + * + * A facet value's `input` is Shopify's own `ProductFilter` JSON. It travels + * into the URL under the facet's id and back into the query untouched, so the + * theme supports a facet it has never heard of — the shape is the store's + * business, not the theme's. + * + * Everything here is a plain href, which is what keeps browsing working with + * no JavaScript. + */ + +import type { StorefrontFilter, StorefrontFilterValue } from "./types"; + +/** Facet params are namespaced so nothing else on the URL is mistaken for one. */ +export const FILTER_PARAM_PREFIX = "filter."; +export const SORT_PARAM = "sort"; +export const AFTER_PARAM = "after"; +export const BEFORE_PARAM = "before"; + +/** + * A price range cannot be a link — the shopper types it — so it travels as two + * plain numbers rather than as a facet's JSON. Every other facet type round + * trips its own `input` untouched. + */ +export const PRICE_MIN_PARAM = "price-min"; +export const PRICE_MAX_PARAM = "price-max"; + +/** Route `searchParams` as a params object, dropping repeated keys. */ +export function toSearchParams( + record: Readonly>, +): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(record)) { + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === "string") { + params.set(key, first); + } + } + return params; +} + +/** + * The `ProductFilter` objects the URL is asking for. + * + * A param that is not valid JSON is dropped rather than failing the page: the + * URL is shopper-editable, and a mangled one should widen the view, not break + * it. + */ +export function parseFilterParams(params: URLSearchParams): readonly unknown[] { + const filters: unknown[] = []; + for (const [key, value] of params.entries()) { + if (!key.startsWith(FILTER_PARAM_PREFIX)) { + continue; + } + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === "object" && parsed !== null) { + filters.push(parsed); + } + } catch { + /* Not a filter this page can honour; ignore it. */ + } + } + const price = parsePriceRange(params); + if (price !== null) { + filters.push({ price }); + } + return filters; +} + +function readNumber(params: URLSearchParams, key: string): number | undefined { + const raw = params.get(key); + if (raw === null || raw.trim() === "") { + return undefined; + } + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : undefined; +} + +/** The typed range, or `null` when the shopper set neither bound. */ +export function parsePriceRange( + params: URLSearchParams, +): { min?: number; max?: number } | null { + const min = readNumber(params, PRICE_MIN_PARAM); + const max = readNumber(params, PRICE_MAX_PARAM); + if (min === undefined && max === undefined) { + return null; + } + /* A reversed range is a typo, not an empty shelf. */ + if (min !== undefined && max !== undefined && min > max) { + return { min: max, max: min }; + } + return { + ...(min === undefined ? {} : { min }), + ...(max === undefined ? {} : { max }), + }; +} + +/** Rewrites the query, keeping every param the caller did not name. */ +export function browseHref( + pathname: string, + params: URLSearchParams, + updates: Readonly>, +): string { + const next = new URLSearchParams(params); + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) { + next.delete(key); + } else { + next.set(key, value); + } + } + const query = next.toString(); + return query.length > 0 ? `${pathname}?${query}` : pathname; +} + +/** + * The param a facet occupies. + * + * Shopify's own facet ids already begin with `filter.` — `filter.v.availability` + * — so the id is the param, and the prefix is what tells a facet param apart + * from everything else on the URL. A store that ever returned an unprefixed id + * still gets a namespaced param. + */ +function paramFor(filter: StorefrontFilter): string { + return filter.id.startsWith(FILTER_PARAM_PREFIX) + ? filter.id + : `${FILTER_PARAM_PREFIX}${filter.id}`; +} + +export function isValueApplied( + params: URLSearchParams, + filter: StorefrontFilter, + value: StorefrontFilterValue, +): boolean { + return params.get(paramFor(filter)) === value.input; +} + +/** + * Toggling a value on or off. + * + * Narrowing returns to the first page: a cursor into the old result rarely + * points anywhere in the new one. + */ +export function toggleValueHref( + pathname: string, + params: URLSearchParams, + filter: StorefrontFilter, + value: StorefrontFilterValue, +): string { + return browseHref(pathname, params, { + [paramFor(filter)]: isValueApplied(params, filter, value) + ? undefined + : value.input, + [AFTER_PARAM]: undefined, + [BEFORE_PARAM]: undefined, + }); +} + +function isFilterParam(key: string): boolean { + return ( + key.startsWith(FILTER_PARAM_PREFIX) || + key === PRICE_MIN_PARAM || + key === PRICE_MAX_PARAM + ); +} + +/** Drops every facet param and the cursor, keeping sort and anything unrelated. */ +export function clearFiltersHref( + pathname: string, + params: URLSearchParams, +): string { + const dropped = [...params.keys()].filter(isFilterParam); + return browseHref( + pathname, + params, + Object.fromEntries( + [...dropped, AFTER_PARAM, BEFORE_PARAM].map((key) => [key, undefined]), + ), + ); +} + +export function hasAppliedFilters(params: URLSearchParams): boolean { + return [...params.keys()].some(isFilterParam); +} + +export function pageHref( + pathname: string, + params: URLSearchParams, + cursor: string, + direction: "next" | "previous", +): string { + return browseHref(pathname, params, { + [AFTER_PARAM]: direction === "next" ? cursor : undefined, + [BEFORE_PARAM]: direction === "previous" ? cursor : undefined, + }); +} diff --git a/src/lib/storefront/fixtures/account.ts b/src/lib/storefront/fixtures/account.ts index 9a71452..1d08e0b 100644 --- a/src/lib/storefront/fixtures/account.ts +++ b/src/lib/storefront/fixtures/account.ts @@ -13,9 +13,13 @@ import type { DemoCartSeedLine } from "../types"; export const DEMO_CART_SEED: readonly DemoCartSeedLine[] = [ { productHandle: "weatherline-shell", - colorwayId: "claystone", + colorwayId: "claystone-charcoal", size: "M", quantity: 1, }, - { productHandle: "ridge-30-field-pack", colorwayId: "charcoal", quantity: 1 }, + { + productHandle: "ridge-30-field-pack", + colorwayId: "charcoal-moss-tan", + quantity: 1, + }, ] as const; diff --git a/src/lib/storefront/fixtures/collections.ts b/src/lib/storefront/fixtures/collections.ts index 35fdec7..9172f46 100644 --- a/src/lib/storefront/fixtures/collections.ts +++ b/src/lib/storefront/fixtures/collections.ts @@ -1,17 +1,79 @@ /** - * Deterministic collection records mirroring the approved live Shopify - * collection contract. Only the static data source may import this file. + * Deterministic collection records mirroring the live Shopify collection + * contract. Only the static data source may import this file. */ -import { COLLECTION_PRESENTATION_PROFILES } from "../collection-presentation"; import type { Collection } from "../types"; +import { PRODUCT_FIXTURES } from "./products"; -export const COLLECTION_FIXTURES: readonly Collection[] = - COLLECTION_PRESENTATION_PROFILES.map((profile) => ({ - handle: profile.handle, - title: profile.title, - fieldCode: profile.fieldCode, - description: profile.description, - heroImage: profile.heroImage, - productHandles: profile.productHandles, - })); +export const COLLECTION_FIXTURES: readonly Collection[] = [ + { + handle: "forward", + title: "Forward", + fieldCode: "FW-00", + description: + "The complete Forward catalog: weather protection, modular carry, and trail footwear selected to work as one compact field system.", + heroImage: { + src: "/images/editorial/trail-movement.webp", + alt: "Runner moving along a high dirt trail at golden hour", + width: 2000, + height: 1333, + }, + /* The complete collection is the whole deterministic catalog. */ + productHandles: PRODUCT_FIXTURES.map((product) => product.handle), + }, + { + handle: "outerwear", + title: "Outerwear", + fieldCode: "OW-01", + description: + "Weatherproof layers built for exposed ground, changing forecasts, and repeat repair across long days outside.", + heroImage: { + src: "/images/editorial/alpine-traverse.webp", + alt: "Hiker traversing an alpine ridgeline above the clouds", + width: 1800, + height: 1201, + }, + productHandles: [ + "weatherline-shell", + "traverse-grid-fleece", + "drift-insulated-vest", + ], + }, + { + handle: "packs", + title: "Packs", + fieldCode: "PK-02", + description: + "Low-bulk carry systems composed for stable movement, deliberate organization, and long miles above the tree line.", + heroImage: { + src: "/images/editorial/camp-tent.webp", + alt: "Tent pitched at dusk beneath a mountain skyline", + width: 2000, + height: 1334, + }, + productHandles: [ + "ridge-30-field-pack", + "approach-18-day-pack", + "waypoint-sling-6", + ], + }, + { + handle: "footwear", + title: "Footwear", + fieldCode: "FT-03", + description: + "Dependable trail footwear tuned for grip, ground feedback, and sustained comfort across changing terrain.", + heroImage: { + src: "/images/editorial/trail-movement.webp", + alt: "Runner moving along a high dirt trail at golden hour", + width: 2000, + height: 1333, + }, + productHandles: [ + "talus-trail-shoe", + "scree-approach-shoe", + "camp-recovery-clog", + ], + }, +]; diff --git a/src/lib/storefront/fixtures/products.ts b/src/lib/storefront/fixtures/products.ts index a8bf450..51c39e4 100644 --- a/src/lib/storefront/fixtures/products.ts +++ b/src/lib/storefront/fixtures/products.ts @@ -1,9 +1,5 @@ /** Static deterministic catalog used only when Shopify is not configured. */ -import { - CATALOG_PRESENTATION_PROFILES, - type CatalogPresentationProfile, -} from "../catalog-presentation"; import type { Money, Product, @@ -28,14 +24,14 @@ function productImage(file: string, alt: string): StorefrontImage { function fixtureColorway( id: string, name: string, - swatchColor: string, filePrefix: string, title: string, ): ProductColorway { return { id, name, - swatchColor, + /* The store sets no native swatches, so selectors use the image. */ + swatchColor: null, images: { primary: productImage( `${filePrefix}-primary.webp`, @@ -84,6 +80,14 @@ function productVariants( } interface StaticProductDefinition { + /** Shopify `productType`, which is what `Product.category` carries. */ + productType: string; + /** Shopify tags minus the ownership marker, as `Product.activities`. */ + tags: readonly string[]; + /** Shopify Color option values, verbatim. */ + colors: readonly string[]; + /** Shopify Size option values, or `null` when the product has no Size. */ + sizes: readonly string[] | null; title: string; price: number; description: string; @@ -95,6 +99,21 @@ interface StaticProductDefinition { const DEFINITIONS: Readonly> = { "weatherline-shell": { + productType: "Outerwear", + tags: [ + "alpine", + "breathable", + "field-system", + "hiking", + "layering", + "outerwear", + "shell-jacket", + "technical-outdoor", + "waterproof", + "windproof", + ], + colors: ["Charcoal / Moss", "Claystone / Charcoal"], + sizes: ["XS", "S", "M", "L", "XL"], title: "Weatherline Shell", price: 248, description: @@ -113,6 +132,21 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["weatherline-charcoal", "weatherline-claystone"], }, "traverse-grid-fleece": { + productType: "Outerwear", + tags: [ + "breathable", + "cool-weather", + "field-system", + "fleece", + "hiking", + "layering", + "midlayer", + "moisture-management", + "outerwear", + "technical-outdoor", + ], + colors: ["Moss / Charcoal", "Claystone / Bone"], + sizes: ["XS", "S", "M", "L", "XL"], title: "Traverse Grid Fleece", price: 148, description: @@ -131,6 +165,21 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["weatherline-charcoal", "weatherline-claystone"], }, "drift-insulated-vest": { + productType: "Outerwear", + tags: [ + "cold-weather", + "field-system", + "hiking", + "insulated-vest", + "layering", + "outerwear", + "packable", + "synthetic-insulation", + "technical-outdoor", + "wind-resistant", + ], + colors: ["Charcoal / Signal", "Dune / Moss"], + sizes: ["XS", "S", "M", "L", "XL"], title: "Drift Insulated Vest", price: 188, description: @@ -149,6 +198,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["weatherline-charcoal", "weatherline-claystone"], }, "ridge-30-field-pack": { + productType: "Packs", + tags: [ + "30l", + "backpack", + "day-hike", + "field-system", + "hiking", + "load-carry", + "packs", + "technical-outdoor", + "trail", + ], + colors: ["Charcoal / Moss / Tan", "Dune / Charcoal"], + sizes: null, title: "Ridge 30 Field Pack", price: 198, description: @@ -167,6 +230,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["ridge-charcoal", "ridge-dune"], }, "approach-18-day-pack": { + productType: "Packs", + tags: [ + "18l", + "backpack", + "daypack", + "field-system", + "hiking", + "lightweight", + "packs", + "scrambling", + "technical-outdoor", + ], + colors: ["Moss / Charcoal", "Claystone / Dune"], + sizes: null, title: "Approach 18 Day Pack", price: 148, description: @@ -182,6 +259,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["ridge-charcoal", "ridge-dune"], }, "waypoint-sling-6": { + productType: "Packs", + tags: [ + "6l", + "everyday-carry", + "field-system", + "lightweight", + "organization", + "packs", + "sling", + "technical-outdoor", + "travel", + ], + colors: ["Charcoal / Signal", "Dune / Moss"], + sizes: null, title: "Waypoint Sling 6", price: 98, description: @@ -197,6 +288,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["ridge-charcoal", "ridge-dune"], }, "talus-trail-shoe": { + productType: "Footwear", + tags: [ + "all-terrain", + "cushioned", + "field-system", + "footwear", + "grip", + "hiking", + "technical-outdoor", + "trail", + "trail-shoe", + ], + colors: ["Charcoal / Moss / Gum", "Limestone / Clay / Moss"], + sizes: ["US 7", "US 8", "US 9", "US 10", "US 11", "US 12", "US 13"], title: "Talus Trail Shoe", price: 168, description: @@ -215,6 +320,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["talus-charcoal", "talus-limestone"], }, "scree-approach-shoe": { + productType: "Footwear", + tags: [ + "approach-shoe", + "field-system", + "footwear", + "grip", + "hiking", + "rubber-rand", + "scrambling", + "technical-outdoor", + "technical-terrain", + ], + colors: ["Charcoal / Gum", "Limestone / Moss"], + sizes: ["US 7", "US 8", "US 9", "US 10", "US 11", "US 12", "US 13"], title: "Scree Approach Shoe", price: 158, description: @@ -233,6 +352,20 @@ const DEFINITIONS: Readonly> = { imagePrefixes: ["talus-charcoal", "talus-limestone"], }, "camp-recovery-clog": { + productType: "Footwear", + tags: [ + "camp", + "comfort", + "cushioned", + "field-system", + "footwear", + "recovery", + "slip-on", + "technical-outdoor", + "weather-tolerant", + ], + colors: ["Charcoal / Moss", "Dune / Claystone"], + sizes: ["US 7", "US 8", "US 9", "US 10", "US 11", "US 12", "US 13"], title: "Camp Recovery Clog", price: 118, description: @@ -249,56 +382,76 @@ const DEFINITIONS: Readonly> = { }, }; -function buildProduct(profile: CatalogPresentationProfile): Product { - const definition = DEFINITIONS[profile.handle]; - if (definition === undefined) { - throw new Error(`Missing static product definition for ${profile.handle}.`); - } - const colorEntries = Object.entries(profile.colorways); - if (colorEntries.length !== definition.imagePrefixes.length) { - throw new Error(`Static colorway images do not match ${profile.handle}.`); +/** The same derivation the Shopify mapper runs, over the same shapes. */ +function colorwayId(label: string): string { + const slug = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "default"; +} + +function buildProduct( + handle: string, + definition: StaticProductDefinition, +): Product { + if (definition.colors.length !== definition.imagePrefixes.length) { + throw new Error(`Static colorway images do not match ${handle}.`); } - const colorways = colorEntries.map(([name, presentation], index) => { - const prefix = definition.imagePrefixes[index]; - if (prefix === undefined) { - throw new Error(`Missing static image prefix for ${profile.handle}.`); - } - return fixtureColorway( - presentation.id, + const colorways = definition.colors.map((name, index) => + fixtureColorway( + colorwayId(name), name, - presentation.swatchColor, - prefix, + definition.imagePrefixes[index] as string, definition.title, - ); - }); + ), + ); const options: readonly ProductOption[] = - profile.optionValues === undefined + definition.sizes === null ? [] - : [{ name: "Size", values: profile.optionValues }]; + : [{ name: "Size", values: definition.sizes }]; const price: Money = { amount: definition.price, currencyCode: "USD" }; return { - handle: profile.handle, + handle, title: definition.title, - subtitle: profile.subtitle, - category: profile.category, - activities: profile.activities, + /* The live mapper uses the store's first `forward.highlights` line; the + * deterministic catalog has no metafields, so it uses the first sentence + * of the same description the store publishes. */ + subtitle: definition.description.split(/(?<=\.)\s/)[0] ?? "", + category: definition.productType, + activities: definition.tags, price, description: definition.description, detailParagraphs: [definition.description, definition.materials], specs: definition.specs, care: definition.care, - repair: profile.repair, + repair: "", colorways, options, variants: productVariants( - profile.handle, + handle, colorways.map((entry) => entry.id), options, price, ), - relatedHandles: profile.relatedHandles, + relatedHandles: [], }; } -export const PRODUCT_FIXTURES: readonly Product[] = - CATALOG_PRESENTATION_PROFILES.map(buildProduct); +const BUILT: readonly Product[] = Object.entries(DEFINITIONS).map( + ([handle, definition]) => buildProduct(handle, definition), +); + +/** + * Related products are the other items sharing a product type — the same rule + * the Shopify mapper applies, so static and live modes cannot diverge. + */ +export const PRODUCT_FIXTURES: readonly Product[] = BUILT.map((product) => ({ + ...product, + relatedHandles: BUILT.filter( + (entry) => + entry.handle !== product.handle && entry.category === product.category, + ) + .map((entry) => entry.handle) + .slice(0, 4), +})); diff --git a/src/lib/storefront/image-source.ts b/src/lib/storefront/image-source.ts index f685f32..16ecec1 100644 --- a/src/lib/storefront/image-source.ts +++ b/src/lib/storefront/image-source.ts @@ -14,8 +14,12 @@ /** The exact Shopify CDN hostname that serves this store's owned media. */ export const SHOPIFY_IMAGE_HOSTNAME = "cdn.shopify.com"; -/** Exact public CDN tenant path for the owned Forward Shopify store files. */ -export const SHOPIFY_IMAGE_PATH_PREFIX = "/s/files/1/0978/4757/4828/files/"; +/** + * Exact public CDN tenant path for the owned Forward Shopify store. Scoped to + * the tenant rather than its `files/` folder: collection images are served + * from the sibling `collections/` folder. + */ +export const SHOPIFY_IMAGE_PATH_PREFIX = "/s/files/1/0978/4757/4828/"; const LOCAL_PRODUCT_IMAGE_PATTERN = /^\/images\/products\/[a-z0-9-]+\.webp$/; diff --git a/src/lib/storefront/product-filters.ts b/src/lib/storefront/product-filters.ts new file mode 100644 index 0000000..59b9f60 --- /dev/null +++ b/src/lib/storefront/product-filters.ts @@ -0,0 +1,119 @@ +/** + * Shopify product filters applied to normalized records. + * + * In Shopify mode the store does this work and this module is not involved: + * the filters travel to the API and it returns the narrowed page. Without + * credentials there is no API, so the deterministic catalog answers the same + * questions locally — the same facet ids, the same `input` JSON, the same + * counts — and a route cannot tell the two apart. + * + * Only the two facets every Shopify store exposes by default are synthesized. + * Anything a merchant enables in Search & Discovery exists solely in the live + * response, which is the point: the theme declares no facet of its own. + */ + +import type { Product, StorefrontFilter, StorefrontFilterValue } from "./types"; + +export const AVAILABILITY_FILTER_ID = "filter.v.availability"; +export const PRICE_FILTER_ID = "filter.v.price"; + +interface AvailabilityFilterInput { + available?: boolean; +} + +interface PriceFilterInput { + price?: { min?: number; max?: number }; +} + +function isAvailable(product: Product): boolean { + return product.variants.some((variant) => variant.availableForSale); +} + +function matchesOne(product: Product, filter: unknown): boolean { + if (typeof filter !== "object" || filter === null) { + return true; + } + const { available } = filter as AvailabilityFilterInput; + if (typeof available === "boolean" && isAvailable(product) !== available) { + return false; + } + const { price } = filter as PriceFilterInput; + if (price !== undefined) { + const amount = product.price.amount; + if (typeof price.min === "number" && amount < price.min) { + return false; + } + if (typeof price.max === "number" && amount > price.max) { + return false; + } + } + return true; +} + +/** Every filter must match, as Shopify's own `filters` argument behaves. */ +export function applyProductFilters( + products: readonly Product[], + filters: readonly unknown[], +): readonly Product[] { + if (filters.length === 0) { + return products; + } + return products.filter((product) => + filters.every((filter) => matchesOne(product, filter)), + ); +} + +function availabilityValue( + products: readonly Product[], + available: boolean, +): StorefrontFilterValue { + return { + id: `${AVAILABILITY_FILTER_ID}.${available ? 1 : 0}`, + label: available ? "In stock" : "Out of stock", + count: products.filter((product) => isAvailable(product) === available) + .length, + input: JSON.stringify({ available }), + }; +} + +/** + * The default facets, described over the collection before the shopper + * narrowed it — so the options never vanish once one is chosen. + */ +export function synthesizeProductFilters( + products: readonly Product[], +): readonly StorefrontFilter[] { + if (products.length === 0) { + return []; + } + const amounts = products.map((product) => product.price.amount); + return [ + { + id: AVAILABILITY_FILTER_ID, + label: "Availability", + type: "LIST", + values: [ + availabilityValue(products, true), + availabilityValue(products, false), + ], + }, + { + id: PRICE_FILTER_ID, + label: "Price", + type: "PRICE_RANGE", + values: [ + { + id: `${PRICE_FILTER_ID}.0`, + label: "Price", + count: products.length, + input: JSON.stringify({ + price: { + min: Math.min(...amounts), + max: Math.max(...amounts), + }, + }), + }, + ], + }, + ]; +} 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/cache-policy.ts b/src/lib/storefront/shopify/cache-policy.ts index db8076b..cd15bec 100644 --- a/src/lib/storefront/shopify/cache-policy.ts +++ b/src/lib/storefront/shopify/cache-policy.ts @@ -6,6 +6,12 @@ export const CATALOG_REVALIDATE_SECONDS = 3600; /** Stable cache namespace; the non-secret store domain is added by the client. */ export const CATALOG_CACHE_KEY = "forward-shopify-catalog-v1"; +/** All-products page reads; the key also carries the request variables. */ +export const ALL_PRODUCTS_CACHE_KEY = "forward-shopify-all-products-v1"; + +/** Per-collection page reads; the key also carries the request variables. */ +export const COLLECTION_CACHE_KEY = "forward-shopify-collection-v1"; + /** Navigation/collection reads share the catalog freshness window. */ export const NAVIGATION_CACHE_KEY = "forward-shopify-navigation-v1"; diff --git a/src/lib/storefront/shopify/client.ts b/src/lib/storefront/shopify/client.ts index 9defe6f..2576d6a 100644 --- a/src/lib/storefront/shopify/client.ts +++ b/src/lib/storefront/shopify/client.ts @@ -15,16 +15,30 @@ import { createShopifyRequestContext, createStorefrontClient, } from "@shopify/hydrogen"; +import type { + ProductCollectionSortKeys, + ProductFilter, + ProductSortKeys, +} from "@shopify/hydrogen/storefront-api-types"; import { unstable_cache } from "next/cache"; - import { + ALL_PRODUCTS_CACHE_KEY, CATALOG_CACHE_KEY, CATALOG_REVALIDATE_SECONDS, + COLLECTION_CACHE_KEY, NAVIGATION_CACHE_KEY, } from "./cache-policy"; +import { + ALL_PRODUCTS_QUERY, + COLLECTION_PRODUCTS_QUERY, +} from "./collection-query"; import type { ShopifyCatalogConfig } from "./env"; import { ShopifyCatalogError, safeErrorLabel } from "./errors"; -import { mapCatalogResult } from "./mapper"; +import { + mapAllProductsResult, + mapCatalogResult, + mapCollectionProductsResult, +} from "./mapper"; import { FOOTER_MENU_HANDLE, NAVIGATION_COLLECTION_LIMIT, @@ -50,6 +64,32 @@ export interface CatalogQueryResult { export type CatalogQueryExecutor = () => Promise; +/** What a route asked Shopify for: facets, order, and a cursor. */ +export interface CollectionQueryVariables { + handle: string; + /** Shopify's own `ProductFilter` objects, round-tripped through the URL. */ + filters: readonly unknown[]; + sortKey: ProductCollectionSortKeys; + reverse: boolean; + first?: number; + last?: number; + startCursor?: string; + endCursor?: string; +} + +export type CollectionQueryExecutor = ( + variables: CollectionQueryVariables, +) => Promise; + +export type AllProductsQueryExecutor = ( + variables: Omit< + CollectionQueryVariables, + "handle" | "sortKey" | "filters" + > & { + sortKey: ProductSortKeys; + }, +) => Promise; + export interface NavigationQueryResult { data?: unknown; errors?: unknown; @@ -250,3 +290,131 @@ export function createNavigationQueryExecutor( ); return () => recoverPartialNavigationResult(cachedExecute); } + +type StorefrontReadClient = ReturnType; + +/** The paging variables every paged read passes through unchanged. */ +function pagingVariables(variables: { + first?: number; + last?: number; + startCursor?: string; + endCursor?: string; +}) { + return { + first: variables.first ?? null, + last: variables.last ?? null, + startCursor: variables.startCursor ?? null, + endCursor: variables.endCursor ?? null, + variantFirst: CATALOG_VARIANT_LIMIT, + mediaFirst: CATALOG_MEDIA_LIMIT, + }; +} + +/** + * A read that pages through products on the shopper's own state. + * + * Unlike the catalog read, the response depends on that state, so the cache + * key carries the variables; a page is still shared by every visitor who asked + * for that exact page. As with the catalog read, the response is validated + * before it can resolve into a persistent cache entry. + */ +function createPagedExecutor( + config: ShopifyCatalogConfig, + options: CatalogQueryExecutorOptions, + label: string, + cacheKey: string, + read: ( + client: StorefrontReadClient, + variables: Variables, + ) => Promise<{ data?: unknown; errors?: unknown }>, + validate: (result: CatalogQueryResult) => unknown, +): (variables: Variables) => Promise { + const client = createStorefrontReadClient(config); + + async function execute(variables: Variables): Promise { + try { + const { data, errors } = await read(client, variables); + const graphQLErrors = readGraphQLErrors(errors, "catalog"); + if (graphQLErrors.length > 0) { + throw new ShopifyCatalogError( + `Storefront API ${label} response contained ${graphQLErrors.length} error(s).`, + ); + } + if (data == null) { + throw new ShopifyCatalogError( + `Storefront API ${label} response did not contain data.`, + ); + } + const result = { data }; + validate(result); + return result; + } catch (error) { + if (error instanceof ShopifyCatalogError) { + throw error; + } + throw new ShopifyCatalogError( + `Storefront API ${label} request failed (${safeErrorLabel(error)}).`, + ); + } + } + + if (options.useNextCache === false) { + return execute; + } + return (variables) => + unstable_cache( + () => execute(variables), + [cacheKey, config.storeDomain, JSON.stringify(variables)], + { revalidate: CATALOG_REVALIDATE_SECONDS }, + )(); +} + +/** Builds the per-collection page executor: facets, order and a cursor. */ +export function createCollectionQueryExecutor( + config: ShopifyCatalogConfig, + options: CatalogQueryExecutorOptions = {}, +): CollectionQueryExecutor { + return createPagedExecutor( + config, + options, + "collection", + COLLECTION_CACHE_KEY, + (client, variables: CollectionQueryVariables) => + client.graphql(COLLECTION_PRODUCTS_QUERY, { + variables: { + handle: variables.handle, + /* Opaque by design: these came from Shopify's own `input` and go + * back unchanged, so the theme never has to know a filter's + * shape to support it. */ + filters: [...variables.filters] as ProductFilter[], + sortKey: variables.sortKey, + reverse: variables.reverse, + ...pagingVariables(variables), + }, + }), + mapCollectionProductsResult, + ); +} + +/** Builds the all-products page executor; the catalog read, but paged. */ +export function createAllProductsQueryExecutor( + config: ShopifyCatalogConfig, + options: CatalogQueryExecutorOptions = {}, +): AllProductsQueryExecutor { + return createPagedExecutor( + config, + options, + "products", + ALL_PRODUCTS_CACHE_KEY, + (client, variables: Parameters[0]) => + client.graphql(ALL_PRODUCTS_QUERY, { + variables: { + query: CATALOG_PRODUCT_FILTER, + sortKey: variables.sortKey, + reverse: variables.reverse, + ...pagingVariables(variables), + }, + }), + mapAllProductsResult, + ); +} diff --git a/src/lib/storefront/shopify/collection-query.ts b/src/lib/storefront/shopify/collection-query.ts new file mode 100644 index 0000000..7f47301 --- /dev/null +++ b/src/lib/storefront/shopify/collection-query.ts @@ -0,0 +1,113 @@ +/** + * Storefront API document for one page of a collection. + * + * Unlike the whole-catalog read, this query is parameterized by the shopper's + * own state: the facets they applied, the order they chose, and the cursor + * they paged to. Shopify does the narrowing and the ordering, and returns the + * facet list for the result alongside it — so a merchant enabling a filter in + * Search & Discovery gets it with no theme change. + */ + +import { gql } from "@shopify/hydrogen"; + +import { PRODUCT_FIELDS_FRAGMENT } from "./queries"; + +/** Products per page; a page is one network read. */ +export const COLLECTION_PAGE_SIZE = 12; + +export const COLLECTION_PRODUCTS_QUERY = gql(` + query ForwardCollectionProducts( + $handle: String! + $first: Int + $last: Int + $startCursor: String + $endCursor: String + $filters: [ProductFilter!] + $sortKey: ProductCollectionSortKeys + $reverse: Boolean + $variantFirst: Int! + $mediaFirst: Int! + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + collection(handle: $handle) { + handle + products( + first: $first + last: $last + before: $startCursor + after: $endCursor + filters: $filters + sortKey: $sortKey + reverse: $reverse + ) { + filters { + id + label + type + values { + id + label + count + input + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + nodes { + ...ForwardProductFields + } + } + } + } + ${PRODUCT_FIELDS_FRAGMENT} +`); + +/** + * One page of the whole catalog. + * + * The Storefront API accepts no `filters` argument outside a collection, so + * the catalog level is sort and paging only. Faceted browsing is a collection + * feature, and the theme does not pretend otherwise by offering controls that + * could not be applied. + */ +export const ALL_PRODUCTS_QUERY = gql(` + query ForwardAllProducts( + $first: Int + $last: Int + $startCursor: String + $endCursor: String + $query: String + $sortKey: ProductSortKeys + $reverse: Boolean + $variantFirst: Int! + $mediaFirst: Int! + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + products( + first: $first + last: $last + before: $startCursor + after: $endCursor + query: $query + sortKey: $sortKey + reverse: $reverse + ) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + nodes { + ...ForwardProductFields + } + } + } + ${PRODUCT_FIELDS_FRAGMENT} +`); diff --git a/src/lib/storefront/shopify/content-client.ts b/src/lib/storefront/shopify/content-client.ts index 55d5883..1598c1e 100644 --- a/src/lib/storefront/shopify/content-client.ts +++ b/src/lib/storefront/shopify/content-client.ts @@ -9,7 +9,7 @@ import type { CatalogQueryExecutorOptions } from "./client"; import { type MappedContentResult, mapContentResult } from "./content-mapper"; import { CONTENT_ARTICLE_LIMIT, - CONTENT_BLOG_HANDLE, + CONTENT_PAGE_LIMIT, CONTENT_QUERY, } from "./content-query"; import type { ShopifyCatalogConfig } from "./env"; @@ -62,8 +62,8 @@ export function createContentQueryExecutor( try { const { data, errors } = await client.graphql(CONTENT_QUERY, { variables: { + pageFirst: CONTENT_PAGE_LIMIT, articleFirst: CONTENT_ARTICLE_LIMIT, - blogHandle: CONTENT_BLOG_HANDLE, }, }); const graphQLErrors = readGraphQLErrors(errors); diff --git a/src/lib/storefront/shopify/content-html-parser.ts b/src/lib/storefront/shopify/content-html-parser.ts index e32b3f5..32271fa 100644 --- a/src/lib/storefront/shopify/content-html-parser.ts +++ b/src/lib/storefront/shopify/content-html-parser.ts @@ -13,6 +13,7 @@ import type { RichTextRun, } from "../types"; import { ShopifyCatalogError } from "./errors"; +import { toThemePath } from "./theme-routes"; interface ParsedRichTextBlock { type: "heading" | "paragraph" | "pullquote"; @@ -77,15 +78,9 @@ const CANONICAL_INTERNAL_ROUTE_PATTERNS = [ /^\/account(?:$|\/)/, ] as const; -const CANONICAL_COLLECTION_ROUTE_MAP = new Map([ - ["/collections/forward", "/shop"], - ["/collections/outerwear", "/shop/outerwear"], - ["/collections/packs", "/shop/packs"], - ["/collections/footwear", "/shop/footwear"], -]); - +/** A Shopify path becomes its theme route; a theme route stays as it is. */ function normalizeCanonicalHref(href: string): string { - return CANONICAL_COLLECTION_ROUTE_MAP.get(href) ?? href; + return toThemePath(href) ?? href; } function fail(message: string): never { @@ -429,26 +424,26 @@ function collectSections( return sections.filter((section) => section.paragraphs.length > 0); } +/** + * A page body as an intro and its headed sections. + * + * The intro is the first paragraph, or the store's summary when the body opens + * with none. Paragraphs under no heading form one untitled section, and an + * empty body is an empty page. + */ export function parsePageHtml( body: string, bodySummary: string | undefined, context: string, - fallbackHeadings: readonly string[], ): { intro: string; sections: readonly PageSection[] } { + if (body.trim().length === 0) { + return { intro: bodySummary?.trim() ?? "", sections: [] }; + } const blocks = extractBlocks(body, context); - const introFromSummary = bodySummary?.trim(); const firstParagraph = blocks.find( (block) => block.type === "paragraph", )?.text; - const intro = - firstParagraph ?? - (introFromSummary && introFromSummary.length > 0 - ? introFromSummary - : undefined); - - if (intro === undefined || intro.length === 0) { - fail(`${context} intro is missing.`); - } + const intro = firstParagraph ?? bodySummary?.trim() ?? ""; const sections: PageSection[] = []; let current: PageSection | null = null; @@ -460,38 +455,20 @@ export function parsePageHtml( sections.push(current); continue; } - if (!introConsumed && firstParagraph === block.text && current === null) { introConsumed = true; continue; } - - if (current !== null) { - current.paragraphs = [...current.paragraphs, block.runs]; + if (current === null) { + current = { heading: "", paragraphs: [] }; + sections.push(current); } + current.paragraphs = [...current.paragraphs, block.runs]; } - const populatedSections = sections.filter( - (section) => section.paragraphs.length > 0, - ); - if (populatedSections.length > 0) { - return { intro, sections: populatedSections }; - } - - const remainingParagraphs = blocks - .filter((block) => block.type !== "heading") - .slice(1); - if (remainingParagraphs.length !== fallbackHeadings.length) { - fail( - `${context} paragraph structure does not match its presentation profile.`, - ); - } return { intro, - sections: remainingParagraphs.map((paragraph, index) => ({ - heading: fallbackHeadings[index] ?? "", - paragraphs: [paragraph.runs], - })), + sections: sections.filter((section) => section.paragraphs.length > 0), }; } diff --git a/src/lib/storefront/shopify/content-mapper.ts b/src/lib/storefront/shopify/content-mapper.ts index e7b881b..78e8241 100644 --- a/src/lib/storefront/shopify/content-mapper.ts +++ b/src/lib/storefront/shopify/content-mapper.ts @@ -1,21 +1,16 @@ -import { - getArticlePresentationProfile, - getPagePresentationProfile, - getPolicyPresentationProfile, -} from "../content-presentation"; -import type { JournalArticle, Policy, StorePage } from "../types"; +import { isShopifyProductImageUrl } from "../image-source"; +import type { + JournalArticle, + Policy, + StorefrontImage, + StorePage, +} from "../types"; import type { ContentQueryResult } from "./content-client"; import { parseArticleHtml, parsePageHtml, parsePolicyHtml, } from "./content-html-parser"; -import { - CONTENT_ARTICLE_HANDLES, - CONTENT_BLOG_HANDLE, - CONTENT_PAGE_HANDLES, - CONTENT_POLICY_HANDLES, -} from "./content-query"; import { ShopifyCatalogError } from "./errors"; export interface MappedContentResult { @@ -76,192 +71,208 @@ function readData(result: ContentQueryResult): Record { return asRecord(result.data, "content data"); } -function readRequiredHandleSet( +/** Nodes keyed by handle; two with one handle is a broken response. */ +function uniqueNodes( nodes: readonly unknown[], - expectedHandles: readonly string[], context: string, - mapNode: (node: Record, handle: string) => T, -): readonly T[] { - const byHandle = new Map(); - - for (const [index, entry] of nodes.entries()) { +): readonly Record[] { + const seen = new Set(); + return nodes.map((entry, index) => { const node = asRecord(entry, `${context} node ${index}`); const handle = asText(node.handle, `${context} node ${index} handle`); - if (!expectedHandles.includes(handle)) { - fail(`${context} returned unexpected handle "${handle}".`); - } - if (handle === "data-sharing-opt-out") { - fail(`${context} returned excluded handle "data-sharing-opt-out".`); - } - if (byHandle.has(handle)) { + if (seen.has(handle)) { fail(`${context} returned duplicate handle "${handle}".`); } - byHandle.set(handle, mapNode(node, handle)); - } - - for (const handle of expectedHandles) { - if (!byHandle.has(handle)) { - fail(`${context} is missing required handle "${handle}".`); - } - } - - return expectedHandles.map((handle) => { - const value = byHandle.get(handle); - if (value === undefined) { - fail(`${context} is missing required handle "${handle}".`); - } - return value; + seen.add(handle); + return node; }); } -function readArticleNodes( - data: Record, +function readConnection( + value: unknown, + context: string, ): readonly Record[] { - const blog = data.blog; - if (blog === null || blog === undefined) { - fail("content blog is missing."); + const connection = asRecord(value, context); + ensureNoPagination(connection.pageInfo, context); + return uniqueNodes(asArray(connection.nodes, `${context} nodes`), context); +} + +/** An optional `forward.*` metafield's text, or empty when the store set none. */ +function metafieldText(value: unknown, context: string): string { + if (value === null || value === undefined) { + return ""; + } + return asOptionalText(asRecord(value, context).value)?.trim() ?? ""; +} + +/** The article's own image, or `null` when it has none the theme can serve. */ +function mapArticleImage( + value: unknown, + title: string, + context: string, +): StorefrontImage | null { + if (value === null || value === undefined) { + return null; } - const blogRecord = asRecord(blog, "content blog"); + const record = asRecord(value, `${context} image`); + const src = asText(record.url, `${context} image url`); + const { width, height, altText } = record; if ( - asText(blogRecord.handle, "content blog handle") !== CONTENT_BLOG_HANDLE + !isShopifyProductImageUrl(src) || + !Number.isInteger(width) || + !Number.isInteger(height) || + (width as number) <= 0 || + (height as number) <= 0 ) { - fail("content blog handle did not match the approved handle."); + return null; } - const articlesConnection = asRecord( - blogRecord.articles, - "content blog articles", - ); - ensureNoPagination(articlesConnection.pageInfo, "content blog articles"); - return readRequiredHandleSet( - asArray(articlesConnection.nodes, "content blog article nodes"), - CONTENT_ARTICLE_HANDLES, - "content blog articles", - (node) => node, - ); + return { + src, + alt: typeof altText === "string" && altText.length > 0 ? altText : title, + width: width as number, + height: height as number, + }; } -function readPageNodes( - data: Record, -): readonly Record[] { - return readRequiredHandleSet( - [ - data.aboutForward, - data.fieldRepair, - data.shippingReturns, - data.contact, - data.materialsAndCare, - data.fitAndSizing, - data.fieldTesting, - ], - CONTENT_PAGE_HANDLES, - "content pages", - (node) => node, - ); +const WORDS_PER_MINUTE = 200; + +function readingMinutes(html: string): number { + const words = html + .replace(/<[^>]*>/g, " ") + .split(/\s+/) + .filter((word) => word.length > 0).length; + return Math.max(1, Math.round(words / WORDS_PER_MINUTE)); } -function readPolicyNodes( - data: Record, -): readonly Record[] { - const shop = asRecord(data.shop, "content shop"); - return readRequiredHandleSet( - [ - shop.privacyPolicy, - shop.refundPolicy, - shop.shippingPolicy, - shop.termsOfService, - ], - CONTENT_POLICY_HANDLES, - "shop policies", - (entry, handle) => { - asText(entry.title, `${handle} title`); - asText(entry.body, `${handle} body`); - return entry; - }, - ); +/** + * A body the theme cannot render safely leaves its one entry out — its route + * answers 404 — rather than taking every other page down with it. The parser + * still refuses the markup; nothing unsafe is ever rendered. + */ +function renderable(parse: () => T): T | null { + try { + return parse(); + } catch (error) { + if (error instanceof ShopifyCatalogError) { + return null; + } + throw error; + } +} + +function isPresent(value: T | null): value is T { + return value !== null; } +/** + * Every article the store publishes, newest first. + * + * The plate is the article's place in the journal, counted from the oldest, + * and the reading time is its length at an average pace — both derived from + * the store rather than kept in a table the theme would have to maintain. + */ export function mapContentArticles( result: ContentQueryResult, ): readonly JournalArticle[] { - const data = readData(result); - return readArticleNodes(data).map((node) => { - const handle = asText(node.handle, "content article handle"); - const presentation = getArticlePresentationProfile(handle); - if (presentation === null) { - fail(`content article "${handle}" has no presentation profile.`); - } - return { - handle, - title: asText(node.title, `${handle} title`), - excerpt: asText(node.excerpt, `${handle} excerpt`), - plate: presentation.plate, - publishedAt: normalizePublishedAt( - node.publishedAt, - `${handle} publishedAt`, - ), - readingMinutes: presentation.readingMinutes, - location: presentation.location, - coordinates: presentation.coordinates, - heroImage: presentation.heroImage, - body: parseArticleHtml( - asText(node.contentHtml, `${handle} contentHtml`), + const nodes = readConnection(readData(result).articles, "content articles"); + return nodes + .map((node, index) => { + const handle = asText(node.handle, "content article handle"); + const title = asText(node.title, `${handle} title`); + const html = asOptionalText(node.contentHtml)?.trim() ?? ""; + const body = renderable(() => + html.length === 0 ? [] : parseArticleHtml(html, handle), + ); + if (body === null) { + return null; + } + return { handle, - ), - } satisfies JournalArticle; - }); + title, + excerpt: asOptionalText(node.excerpt)?.trim() ?? "", + plate: `No. ${String(nodes.length - index).padStart(2, "0")}`, + publishedAt: normalizePublishedAt( + node.publishedAt, + `${handle} publishedAt`, + ), + readingMinutes: readingMinutes(html), + location: metafieldText(node.location, `${handle} location`), + coordinates: metafieldText(node.coordinates, `${handle} coordinates`), + heroImage: mapArticleImage(node.image, title, handle), + body, + } satisfies JournalArticle; + }) + .filter(isPresent); } +/** + * Every page the store publishes. A page has no eyebrow or image of its own + * in Shopify, so the page hero falls back to its section settings. + */ export function mapContentPages( result: ContentQueryResult, ): readonly StorePage[] { - const data = readData(result); - return readPageNodes(data).map((node) => { - const handle = asText(node.handle, "content page handle"); - const presentation = getPagePresentationProfile(handle); - if (presentation === null) { - fail(`content page "${handle}" has no presentation profile.`); - } - const mapped = parsePageHtml( - asText(node.body, `${handle} body`), - asOptionalText(node.bodySummary), - handle, - presentation.sectionHeadings, - ); - return { - handle, - title: asText(node.title, `${handle} title`), - eyebrow: presentation.eyebrow, - intro: mapped.intro, - heroImage: presentation.heroImage, - sections: mapped.sections, - } satisfies StorePage; - }); + return readConnection(readData(result).pages, "content pages") + .map((node) => { + const handle = asText(node.handle, "content page handle"); + const title = asText(node.title, `${handle} title`); + const mapped = renderable(() => + parsePageHtml( + asOptionalText(node.body) ?? "", + asOptionalText(node.bodySummary), + handle, + ), + ); + if (mapped === null) { + return null; + } + return { + handle, + title, + eyebrow: "", + intro: mapped.intro, + sections: mapped.sections, + } satisfies StorePage; + }) + .filter(isPresent); +} + +/** The store's policies; one it has not written is simply absent. */ +function readPolicyNodes( + data: Record, +): readonly Record[] { + const shop = asRecord(data.shop, "content shop"); + return uniqueNodes( + [ + shop.privacyPolicy, + shop.refundPolicy, + shop.shippingPolicy, + shop.termsOfService, + ].filter((entry) => entry !== null && entry !== undefined), + "shop policies", + ); } export function mapContentPolicies( result: ContentQueryResult, ): readonly Policy[] { - const data = readData(result); - return readPolicyNodes(data).map((node) => mapPolicyNode(node)); + return readPolicyNodes(readData(result)).map(mapPolicyNode).filter(isPresent); } -function mapPolicyNode(node: Record): Policy { +function mapPolicyNode(node: Record): Policy | null { const handle = asText(node.handle, "policy handle"); const title = asText(node.title, `${handle} title`); - const presentation = getPolicyPresentationProfile(handle); - if (presentation === null) { - fail(`policy "${handle}" has no presentation profile.`); + const body = asText(node.body, `${handle} body`); + const sections = renderable(() => parsePolicyHtml(body, title, handle)); + if (sections === null) { + return null; } return { handle, title, - summary: presentation.summary, + summary: "", updatedAt: undefined, - sections: parsePolicyHtml( - asText(node.body, `${handle} body`), - title, - handle, - ), + sections, } satisfies Policy; } @@ -269,14 +280,10 @@ export function mapContentPolicy( result: ContentQueryResult, handle: string, ): Policy | null { - const data = readData(result); - const node = readPolicyNodes(data).find( + const node = readPolicyNodes(readData(result)).find( (entry) => asText(entry.handle, "policy handle") === handle, ); - if (node === undefined) { - return null; - } - return mapPolicyNode(node); + return node === undefined ? null : mapPolicyNode(node); } export function validateContentResult(result: ContentQueryResult): void { diff --git a/src/lib/storefront/shopify/content-query.ts b/src/lib/storefront/shopify/content-query.ts index 7e1c014..9855d36 100644 --- a/src/lib/storefront/shopify/content-query.ts +++ b/src/lib/storefront/shopify/content-query.ts @@ -1,82 +1,59 @@ import { gql } from "@shopify/hydrogen"; -export const CONTENT_PAGE_HANDLES = [ - "about-forward", - "field-repair", - "shipping-returns", - "contact", - "materials-and-care", - "fit-and-sizing", - "field-testing", -] as const; - -export const CONTENT_BLOG_HANDLE = "field-notes" as const; - -export const CONTENT_ARTICLE_HANDLES = [ - "layering-for-moving-weather", - "packing-thirty-liters-for-a-long-day", - "reading-the-trail-underfoot", - "how-we-test-a-shell-before-calling-it-weatherproof", - "a-two-day-kit-built-around-nine-kilograms", - "repair-notes-what-five-years-of-use-should-look-like", -] as const; - -export const CONTENT_POLICY_HANDLES = [ - "privacy-policy", - "refund-policy", - "shipping-policy", - "terms-of-service", -] as const; - -export const CONTENT_ARTICLE_LIMIT = 10; - -const PAGE_FIELDS = ` - handle - title - bodySummary - body -`; +/* + * ponytail: one bounded read per list. A store past either bound fails the + * content read (a truncated list would silently hide content); cursor paging + * is the upgrade when a store outgrows it. + */ +export const CONTENT_PAGE_LIMIT = 100; +export const CONTENT_ARTICLE_LIMIT = 100; +/** + * Every page and article the store publishes, plus its policies. + * + * The theme has one journal, so articles are read across all blogs, newest + * first. Location and coordinates are optional `forward.*` article + * metafields; a store that sets neither renders articles without them. + */ export const CONTENT_QUERY = gql(` query ForwardContent( + $pageFirst: Int! $articleFirst: Int! - $blogHandle: String! $country: CountryCode $language: LanguageCode ) @inContext(country: $country, language: $language) { - aboutForward: page(handle: "about-forward") { - ${PAGE_FIELDS} - } - fieldRepair: page(handle: "field-repair") { - ${PAGE_FIELDS} - } - shippingReturns: page(handle: "shipping-returns") { - ${PAGE_FIELDS} - } - contact: page(handle: "contact") { - ${PAGE_FIELDS} - } - materialsAndCare: page(handle: "materials-and-care") { - ${PAGE_FIELDS} - } - fitAndSizing: page(handle: "fit-and-sizing") { - ${PAGE_FIELDS} - } - fieldTesting: page(handle: "field-testing") { - ${PAGE_FIELDS} + pages(first: $pageFirst) { + pageInfo { + hasNextPage + } + nodes { + handle + title + bodySummary + body + } } - blog(handle: $blogHandle) { - handle - articles(first: $articleFirst) { - pageInfo { - hasNextPage + articles(first: $articleFirst, sortKey: PUBLISHED_AT, reverse: true) { + pageInfo { + hasNextPage + } + nodes { + handle + title + excerpt + contentHtml + publishedAt + image { + url + width + height + altText + } + location: metafield(namespace: "forward", key: "location") { + value } - nodes { - handle - title - excerpt - contentHtml - publishedAt + coordinates: metafield(namespace: "forward", key: "coordinates") { + value } } } diff --git a/src/lib/storefront/shopify/data-source.ts b/src/lib/storefront/shopify/data-source.ts index 7158377..4450e65 100644 --- a/src/lib/storefront/shopify/data-source.ts +++ b/src/lib/storefront/shopify/data-source.ts @@ -10,29 +10,37 @@ * contracts when malformed remote data would otherwise take down routes. */ -import { - filterAndSortProducts, - searchNormalizedProducts, -} from "../catalog-query"; +import { searchNormalizedProducts } from "../catalog-query"; import type { StorefrontDataSource } from "../data-source"; +import { catalogSortArguments, collectionSortArguments } from "../sort"; import type { Collection, + CollectionProductsPage, + CollectionProductsQuery, DemoCartSeedLine, JournalArticle, Policy, Product, - ProductListFilter, - ProductSort, SiteNavigation, StorePage, ThemeContent, } from "../types"; import { CATALOG_REVALIDATE_SECONDS } from "./cache-policy"; -import type { CatalogQueryExecutor, NavigationQueryExecutor } from "./client"; +import type { + AllProductsQueryExecutor, + CatalogQueryExecutor, + CollectionQueryExecutor, + NavigationQueryExecutor, +} from "./client"; +import { COLLECTION_PAGE_SIZE } from "./collection-query"; import type { ContentQueryExecutor } from "./content-client"; import type { MappedContentResult } from "./content-mapper"; -import { ShopifyCatalogError, safeErrorLabel } from "./errors"; -import { mapCatalogResult } from "./mapper"; +import { ShopifyCatalogError } from "./errors"; +import { + mapAllProductsResult, + mapCatalogResult, + mapCollectionProductsResult, +} from "./mapper"; import { mapCollectionsResult, mapFooterMenuResult, @@ -47,18 +55,14 @@ export interface ShopifyCatalogDataSourceOptions { /** Static implementation backing every not-yet-live domain. */ base: StorefrontDataSource; execute: CatalogQueryExecutor; - executeContent?: ContentQueryExecutor; + executeCollection: CollectionQueryExecutor; + executeAllProducts: AllProductsQueryExecutor; + executeContent: ContentQueryExecutor; executeNavigation: NavigationQueryExecutor; /** Configured store origin used to reject cross-store menu URLs. */ storeDomain: string; /** Selected Shopify primary-menu handle. */ mainMenuHandle: string; - /** Injectable sanitized observer for navigation fallback events. */ - onNavigationFallback?: (error: ShopifyCatalogError) => void; - /** Injectable sanitized observer for Footer-menu fallback events. */ - onFooterFallback?: (error: ShopifyCatalogError) => void; - /** Injectable sanitized observer for collection-structure fallback events. */ - onCollectionFallback?: (error: ShopifyCatalogError) => void; /** * Standalone verifier/test fallback only. Production routes leave this false * so every read reaches the Next Data Cache and registers its dependency. @@ -83,13 +87,12 @@ interface ContentCacheEntry { export class ShopifyCatalogDataSource implements StorefrontDataSource { readonly #base: StorefrontDataSource; readonly #execute: CatalogQueryExecutor; - readonly #executeContent: ContentQueryExecutor | null; + readonly #executeCollection: CollectionQueryExecutor; + readonly #executeAllProducts: AllProductsQueryExecutor; + readonly #executeContent: ContentQueryExecutor; readonly #executeNavigation: NavigationQueryExecutor; readonly #storeDomain: string; readonly #mainMenuHandle: string; - readonly #onNavigationFallback: (error: ShopifyCatalogError) => void; - readonly #onFooterFallback: (error: ShopifyCatalogError) => void; - readonly #onCollectionFallback: (error: ShopifyCatalogError) => void; readonly #useProcessCache: boolean; readonly #ttlMs: number; readonly #now: () => number; @@ -98,58 +101,22 @@ export class ShopifyCatalogDataSource implements StorefrontDataSource { #inFlight: Promise | null = null; #contentCached: ContentCacheEntry | null = null; #contentInFlight: Promise | null = null; - #navigationFallbackReported = false; - #footerFallbackReported = false; - #collectionFallbackReported = false; constructor(options: ShopifyCatalogDataSourceOptions) { this.#base = options.base; this.#execute = options.execute; - this.#executeContent = options.executeContent ?? null; + this.#executeCollection = options.executeCollection; + this.#executeAllProducts = options.executeAllProducts; + this.#executeContent = options.executeContent; this.#executeNavigation = options.executeNavigation; this.#storeDomain = options.storeDomain; this.#mainMenuHandle = options.mainMenuHandle; - this.#onCollectionFallback = - options.onCollectionFallback ?? - ((error) => { - console.warn( - `[storefront] using static collection-structure fallback (${safeErrorLabel(error)}).`, - ); - }); - this.#onNavigationFallback = - options.onNavigationFallback ?? - ((error) => { - console.warn( - `[storefront] using static main-navigation fallback (${safeErrorLabel(error)}).`, - ); - }); - this.#onFooterFallback = - options.onFooterFallback ?? - ((error) => { - console.warn( - `[storefront] using static footer-navigation fallback (${safeErrorLabel(error)}).`, - ); - }); this.#useProcessCache = options.useProcessCache ?? true; this.#ttlMs = options.ttlMs ?? CATALOG_REVALIDATE_SECONDS * MILLISECONDS_PER_SECOND; this.#now = options.now ?? Date.now; } - #reportNavigationFallback(error: ShopifyCatalogError): void { - if (!this.#navigationFallbackReported) { - this.#onNavigationFallback(error); - this.#navigationFallbackReported = true; - } - } - - #reportFooterFallback(error: ShopifyCatalogError): void { - if (!this.#footerFallbackReported) { - this.#onFooterFallback(error); - this.#footerFallbackReported = true; - } - } - async #loadCatalog(): Promise { if (!this.#useProcessCache) { return mapCatalogResult(await this.#execute()); @@ -177,24 +144,10 @@ export class ShopifyCatalogDataSource implements StorefrontDataSource { } async #loadCollections(): Promise { - try { - return mapCollectionsResult(await this.#executeNavigation()); - } catch (error) { - if (!(error instanceof ShopifyCatalogError)) { - throw error; - } - if (!this.#collectionFallbackReported) { - this.#onCollectionFallback(error); - this.#collectionFallbackReported = true; - } - return this.#base.listCollections(); - } + return mapCollectionsResult(await this.#executeNavigation()); } - async #loadContent(): Promise { - if (this.#executeContent === null) { - return null; - } + async #loadContent(): Promise { if (!this.#useProcessCache) { return this.#executeContent(); } @@ -221,11 +174,8 @@ export class ShopifyCatalogDataSource implements StorefrontDataSource { /* ---- Shopify-owned catalog reads ------------------------------------- */ - async listProducts( - filter: ProductListFilter = {}, - sort: ProductSort = "featured", - ): Promise { - return filterAndSortProducts(await this.#loadCatalog(), filter, sort); + async listProducts(): Promise { + return this.#loadCatalog(); } async getProduct(handle: string): Promise { @@ -268,105 +218,99 @@ export class ShopifyCatalogDataSource implements StorefrontDataSource { }); } - async getNavigation(): Promise { - const base = await this.#base.getNavigation(); - let result: Awaited>; - try { - result = await this.#executeNavigation(); - } catch (error) { - if (!(error instanceof ShopifyCatalogError)) { - throw error; - } - this.#reportNavigationFallback(error); - this.#reportFooterFallback(error); - return base; - } - - let primary: SiteNavigation["primary"]; - try { - const mappedPrimary = mapMainMenuResult( - result, - this.#storeDomain, - this.#mainMenuHandle, - ); - const search = base.primary.filter((item) => item.href === "/search"); - if (search.length !== 1) { - throw new ShopifyCatalogError( - "Theme navigation must define exactly one Search destination.", - ); - } - primary = [...mappedPrimary, ...search]; - } catch (error) { - if (!(error instanceof ShopifyCatalogError)) { - throw error; - } - this.#reportNavigationFallback(error); - primary = base.primary; - } + /** + * One page of a collection, read live. + * + * The shopper's filters, order and cursor become query variables, so the + * store does the narrowing and returns the facets it offers for the result. + * Nothing here decides what a filter means. + */ + async getCollectionPage( + handle: string, + query: CollectionProductsQuery = {}, + ): Promise { + const { sortKey, reverse } = collectionSortArguments( + query.sort ?? "featured", + ); + const pageBy = query.pageBy ?? COLLECTION_PAGE_SIZE; + /* A start cursor reads backwards, which Shopify expresses as `last`. */ + const backwards = query.before !== undefined; + return mapCollectionProductsResult( + await this.#executeCollection({ + handle, + filters: query.filters ?? [], + sortKey, + reverse, + ...(backwards + ? { last: pageBy, startCursor: query.before } + : { first: pageBy, endCursor: query.after }), + }), + ); + } - let footerColumns: SiteNavigation["footerColumns"]; - try { - footerColumns = mapFooterMenuResult(result, this.#storeDomain); - } catch (error) { - if (!(error instanceof ShopifyCatalogError)) { - throw error; - } - this.#reportFooterFallback(error); - footerColumns = base.footerColumns; - } + async getProductsPage( + query: CollectionProductsQuery = {}, + ): Promise { + const { sortKey, reverse } = catalogSortArguments(query.sort ?? "featured"); + const pageBy = query.pageBy ?? COLLECTION_PAGE_SIZE; + const backwards = query.before !== undefined; + return mapAllProductsResult( + await this.#executeAllProducts({ + sortKey, + reverse, + ...(backwards + ? { last: pageBy, startCursor: query.before } + : { first: pageBy, endCursor: query.after }), + }), + ); + } + /** + * The store's own menus. Search and the utility links are the theme's own + * destinations rather than menu entries, so they come from the theme. + */ + async getNavigation(): Promise { + const [theme, result] = await Promise.all([ + this.#base.getNavigation(), + this.#executeNavigation(), + ]); return { - primary, - utility: base.utility, - footerColumns, + primary: [ + ...mapMainMenuResult(result, this.#storeDomain, this.#mainMenuHandle), + ...theme.primary.filter((item) => item.href === "/search"), + ], + utility: theme.utility, + footerColumns: mapFooterMenuResult(result, this.#storeDomain), }; } /* ---- Shopify-owned content reads ------------------------------------- */ async listArticles(): Promise { - const content = await this.#loadContent(); - return content === null ? this.#base.listArticles() : content.articles; + return (await this.#loadContent()).articles; } async getArticle(handle: string): Promise { - const content = await this.#loadContent(); - return ( - (content === null - ? null - : content.articles.find((article) => article.handle === handle)) ?? - (content === null ? this.#base.getArticle(handle) : null) - ); + const { articles } = await this.#loadContent(); + return articles.find((article) => article.handle === handle) ?? null; } async listPages(): Promise { - const content = await this.#loadContent(); - return content === null ? this.#base.listPages() : content.pages; + return (await this.#loadContent()).pages; } async getPage(handle: string): Promise { - const content = await this.#loadContent(); - return ( - (content === null - ? null - : content.pages.find((page) => page.handle === handle)) ?? - (content === null ? this.#base.getPage(handle) : null) - ); + const { pages } = await this.#loadContent(); + return pages.find((page) => page.handle === handle) ?? null; } async listPolicies(): Promise { - const content = await this.#loadContent(); - return content === null ? this.#base.listPolicies() : content.policies; + return (await this.#loadContent()).policies; } async getPolicy(handle: string): Promise { - const content = await this.#loadContent(); - return ( - (content === null - ? null - : content.policies.find((policy) => policy.handle === handle)) ?? - (content === null ? this.#base.getPolicy(handle) : null) - ); + const { policies } = await this.#loadContent(); + return policies.find((policy) => policy.handle === handle) ?? null; } async getThemeContent(): Promise { diff --git a/src/lib/storefront/shopify/mapper.ts b/src/lib/storefront/shopify/mapper.ts index e3ad910..af6631c 100644 --- a/src/lib/storefront/shopify/mapper.ts +++ b/src/lib/storefront/shopify/mapper.ts @@ -5,20 +5,16 @@ * 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 { + CollectionProductsPage, ColorwayImages, Money, Product, @@ -26,6 +22,8 @@ import type { ProductOption, ProductVariant, SpecRow, + StorefrontFilter, + StorefrontFilterType, StorefrontImage, } from "../types"; import type { CatalogQueryResult } from "./client"; @@ -35,6 +33,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 +193,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 +205,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 +232,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 +253,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 +373,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 +420,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 +680,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 +756,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 +765,7 @@ function mapVariants( } const selectionKey = [ - presentationColorway.id, + colorwayId(color.value), ...selectedOptions.map(({ name, value }) => `${name}:${value}`), ].join("\u001f"); if (selections.has(selectionKey)) { @@ -779,7 +779,7 @@ function mapVariants( } variants.push({ id, - colorwayId: presentationColorway.id, + colorwayId: colorwayId(color.value), selectedOptions, price, compareAtPrice: mapNullableMoney( @@ -793,46 +793,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 +804,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 +812,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 +824,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 +841,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 +887,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 +941,126 @@ 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), + })); +} + +/* -------------------------------------------------------------------------- */ +/* Collection page */ +/* -------------------------------------------------------------------------- */ + +const FILTER_TYPES = new Set([ + "LIST", + "PRICE_RANGE", + "BOOLEAN", +]); + +function mapStorefrontFilter(value: unknown, index: number): StorefrontFilter { + const context = `collection filter ${index}`; + const record = asRecord(value, context); + const type = asText(record.type, `${context} type`); + if (!FILTER_TYPES.has(type as StorefrontFilterType)) { + fail(`${context} has unsupported type "${type}".`); + } + return { + id: asText(record.id, `${context} id`), + label: asText(record.label, `${context} label`), + type: type as StorefrontFilterType, + values: asArray(record.values, `${context} values`).map( + (entry, valueIndex) => { + const valueContext = `${context} value ${valueIndex}`; + const valueRecord = asRecord(entry, valueContext); + const count = valueRecord.count; + if (!Number.isInteger(count) || (count as number) < 0) { + fail(`${valueContext} has no usable count.`); + } + return { + id: asText(valueRecord.id, `${valueContext} id`), + label: asText(valueRecord.label, `${valueContext} label`), + count: count as number, + /* Opaque on purpose: this is Shopify's own ProductFilter JSON and + * it travels to the URL and back untouched. */ + input: asText(valueRecord.input, `${valueContext} input`), + }; + }, + ), + }; +} + +function optionalCursor(value: unknown, context: string): string | null { + if (value === null || value === undefined) { + return null; + } + return asText(value, context); +} + +/** + * Validates one page of a collection. + * + * `null` means the store has no such collection, which the route turns into a + * 404. An unknown handle is not an error. + */ +export function mapCollectionProductsResult( + result: CatalogQueryResult, +): CollectionProductsPage | null { + if (Array.isArray(result.errors) && result.errors.length > 0) { + fail( + `Storefront API returned ${result.errors.length} GraphQL error(s) for the collection query.`, + ); + } + const data = asRecord(result.data, "collection response data"); + if (data.collection === null || data.collection === undefined) { + return null; + } + const collection = asRecord(data.collection, "collection"); + return mapProductsConnection(collection.products); +} + +/** One page of the whole catalog, shaped exactly like a collection page. */ +export function mapAllProductsResult( + result: CatalogQueryResult, +): CollectionProductsPage { + if (Array.isArray(result.errors) && result.errors.length > 0) { + fail( + `Storefront API returned ${result.errors.length} GraphQL error(s) for the products query.`, + ); + } + const data = asRecord(result.data, "products response data"); + return mapProductsConnection(data.products); +} + +function mapProductsConnection(value: unknown): CollectionProductsPage { + const products = asRecord(value, "products connection"); + const pageInfo = asRecord(products.pageInfo, "products pageInfo"); + + return { + products: asArray(products.nodes, "product nodes").map((node, index) => + mapProduct(node, index), + ), + /* Absent outside a collection: the API accepts no filters there, so a + * catalog page carries none. */ + filters: + products.filters === undefined || products.filters === null + ? [] + : asArray(products.filters, "product filters").map(mapStorefrontFilter), + pageInfo: { + hasNextPage: pageInfo.hasNextPage === true, + hasPreviousPage: pageInfo.hasPreviousPage === true, + startCursor: optionalCursor(pageInfo.startCursor, "products startCursor"), + endCursor: optionalCursor(pageInfo.endCursor, "products endCursor"), + }, + }; } diff --git a/src/lib/storefront/shopify/navigation-mapper.ts b/src/lib/storefront/shopify/navigation-mapper.ts index d105fd3..4db046f 100644 --- a/src/lib/storefront/shopify/navigation-mapper.ts +++ b/src/lib/storefront/shopify/navigation-mapper.ts @@ -1,179 +1,14 @@ -import { - COLLECTION_PRESENTATION_PROFILES, - type CollectionPresentationProfile, -} from "../collection-presentation"; -import type { Collection, FooterColumn, NavItem } from "../types"; +import { isShopifyProductImageUrl } from "../image-source"; +import type { + Collection, + FooterColumn, + NavItem, + StorefrontImage, +} from "../types"; import type { NavigationQueryResult } from "./client"; import { ShopifyCatalogError } from "./errors"; import { FOOTER_MENU_HANDLE } from "./navigation-query"; - -interface ExpectedMenuItem { - label: string; - href: string; - sourcePaths: readonly string[]; - children?: readonly ExpectedMenuItem[]; -} - -const EXPECTED_MENU = [ - { - label: "Shop", - href: "/shop", - sourcePaths: ["/shop", "/collections/forward"], - children: [ - { - label: "Shop all", - href: "/shop", - sourcePaths: ["/shop", "/collections/forward"], - }, - { - label: "Outerwear", - href: "/shop/outerwear", - sourcePaths: ["/shop/outerwear", "/collections/outerwear"], - }, - { - label: "Packs", - href: "/shop/packs", - sourcePaths: ["/shop/packs", "/collections/packs"], - }, - { - label: "Footwear", - href: "/shop/footwear", - sourcePaths: ["/shop/footwear", "/collections/footwear"], - }, - ], - }, - { - label: "Field Notes", - href: "/journal", - sourcePaths: ["/journal", "/blogs/field-notes"], - }, - { - label: "About", - href: "/pages/about-forward", - sourcePaths: ["/pages/about-forward"], - children: [ - { - label: "Materials & Care", - href: "/pages/materials-and-care", - sourcePaths: ["/pages/materials-and-care"], - }, - { - label: "Fit & Sizing", - href: "/pages/fit-and-sizing", - sourcePaths: ["/pages/fit-and-sizing"], - }, - { - label: "Field Testing", - href: "/pages/field-testing", - sourcePaths: ["/pages/field-testing"], - }, - { - label: "Field Repair", - href: "/pages/field-repair", - sourcePaths: ["/pages/field-repair"], - }, - { - label: "Shipping & Returns", - href: "/pages/shipping-returns", - sourcePaths: ["/pages/shipping-returns"], - }, - { - label: "Contact", - href: "/pages/contact", - sourcePaths: ["/pages/contact"], - }, - ], - }, -] as const satisfies readonly ExpectedMenuItem[]; - -const EXPECTED_FOOTER_MENU = [ - { - label: "Shop", - href: "/shop", - sourcePaths: ["/shop", "/collections/forward"], - children: [ - { - label: "All products", - href: "/shop", - sourcePaths: ["/shop", "/collections/forward"], - }, - { - label: "Outerwear", - href: "/shop/outerwear", - sourcePaths: ["/shop/outerwear", "/collections/outerwear"], - }, - { - label: "Packs", - href: "/shop/packs", - sourcePaths: ["/shop/packs", "/collections/packs"], - }, - { - label: "Footwear", - href: "/shop/footwear", - sourcePaths: ["/shop/footwear", "/collections/footwear"], - }, - ], - }, - { - label: "Company", - href: "/pages/about-forward", - sourcePaths: ["/pages/about-forward"], - children: [ - { - label: "About Forward", - href: "/pages/about-forward", - sourcePaths: ["/pages/about-forward"], - }, - { - label: "Field Repair", - href: "/pages/field-repair", - sourcePaths: ["/pages/field-repair"], - }, - { - label: "Shipping & Returns", - href: "/pages/shipping-returns", - sourcePaths: ["/pages/shipping-returns"], - }, - { - label: "Contact", - href: "/pages/contact", - sourcePaths: ["/pages/contact"], - }, - ], - }, - { - label: "Support", - href: "/account", - sourcePaths: ["/account"], - children: [ - { - label: "Account", - href: "/account", - sourcePaths: ["/account"], - }, - { - label: "Shipping", - href: "/policies/shipping-policy", - sourcePaths: ["/policies/shipping-policy"], - }, - { - label: "Returns", - href: "/policies/refund-policy", - sourcePaths: ["/policies/return-policy", "/policies/refund-policy"], - }, - { - label: "Privacy", - href: "/policies/privacy-policy", - sourcePaths: ["/policies/privacy-policy"], - }, - { - label: "Terms", - href: "/policies/terms-of-service", - sourcePaths: ["/policies/terms-of-service"], - }, - ], - }, -] as const satisfies readonly ExpectedMenuItem[]; +import { toThemePath } from "./theme-routes"; export interface NavigationSnapshot { primary: readonly NavItem[]; @@ -205,182 +40,192 @@ function asText(value: unknown, context: string): string { return value.trim(); } -function readInternalPath( - value: unknown, - context: string, - storeDomain: string, -): string { +/** + * The path a menu URL names on this store, or `null` when it points anywhere + * else. Query and fragment state are dropped: a menu link names a + * destination, never the state a shopper carries into it. + */ +function readStorePath(value: unknown, context: string, storeDomain: string) { const raw = asText(value, `${context} url`); if (raw.startsWith("//")) { - fail(`${context} url must be relative or use an explicit HTTPS origin.`); - } - if (raw.includes("?") || raw.includes("#")) { - fail(`${context} url must not include query or fragment delimiters.`); + return null; } let url: URL; try { url = new URL(raw, "https://forward-navigation.invalid"); } catch { - return fail(`${context} url is invalid.`); + return null; } - const isSyntheticRelativeOrigin = - url.hostname === "forward-navigation.invalid" && - raw.startsWith("/") && - !raw.startsWith("//"); - const isConfiguredStoreOrigin = + const isRelative = + url.hostname === "forward-navigation.invalid" && raw.startsWith("/"); + const isStoreOrigin = url.protocol === "https:" && url.hostname === storeDomain.toLowerCase() && url.username.length === 0 && url.password.length === 0 && url.port.length === 0; - if (!isSyntheticRelativeOrigin && !isConfiguredStoreOrigin) { - fail(`${context} url must target the configured Shopify store.`); + if (!isRelative && !isStoreOrigin) { + return null; } - if (url.search.length > 0 || url.hash.length > 0) { - fail(`${context} url must not include query or fragment state.`); - } - const path = url.pathname.replace(/\/$/, "") || "/"; - return path; + return url.pathname.replace(/\/$/, "") || "/"; } +/** + * One menu entry exactly as the merchant arranged it. + * + * The query reads two levels of items, so only a top-level entry maps its + * children. An entry the theme has no route for is `null`, with its children. + */ function mapMenuItem( value: unknown, - expected: ExpectedMenuItem, context: string, storeDomain: string, -): NavItem { + withChildren: boolean, +): NavItem | null { const record = asRecord(value, context); const label = asText(record.title, `${context} title`); - if (label !== expected.label) { - fail(`${context} title must be "${expected.label}".`); - } - const sourcePath = readInternalPath(record.url, context, storeDomain); - if (!expected.sourcePaths.includes(sourcePath)) { - fail(`${context} url does not map to ${expected.href}.`); + const path = readStorePath(record.url, context, storeDomain); + const href = path === null ? null : toThemePath(path); + if (href === null) { + return null; } - - const rawChildren = asArray(record.items, `${context} items`); - const expectedChildren = expected.children ?? []; - if (rawChildren.length !== expectedChildren.length) { - fail( - `${context} must contain exactly ${expectedChildren.length} children.`, - ); - } - const children = expectedChildren.map((child, index) => - mapMenuItem( - rawChildren[index], - child, - `${context} child ${index}`, - storeDomain, - ), - ); - - return children.length === 0 - ? { href: expected.href, label } - : { href: expected.href, label, children }; + const children = withChildren + ? mapMenuItems(record.items, `${context} child`, storeDomain, false) + : []; + return children.length === 0 ? { href, label } : { href, label, children }; } -function mapMenu( +function mapMenuItems( value: unknown, + context: string, storeDomain: string, - menuHandle: string, + withChildren: boolean, ): readonly NavItem[] { + return asArray(value, `${context} items`) + .map((item, index) => + mapMenuItem(item, `${context} ${index}`, storeDomain, withChildren), + ) + .filter((item): item is NavItem => item !== null); +} + +/** A menu's top-level items, or none when the store has no such menu. */ +function readMenuItems(value: unknown, menuHandle: string): unknown { if (value === null || value === undefined) { - fail(`Shopify menu "${menuHandle}" is missing.`); + return []; } const menu = asRecord(value, `Shopify menu "${menuHandle}"`); if (menu.handle !== menuHandle) { - fail(`Shopify returned the wrong menu handle.`); + fail(`Shopify returned the wrong menu for "${menuHandle}".`); } - const items = asArray(menu.items, `Shopify menu "${menuHandle}" items`); - if (items.length !== EXPECTED_MENU.length) { - fail(`Shopify menu "${menuHandle}" must contain exactly three items.`); - } - return EXPECTED_MENU.map((expected, index) => - mapMenuItem( - items[index], - expected, - `Shopify menu item ${index}`, - storeDomain, - ), + return menu.items; +} + +function mapMenu( + value: unknown, + storeDomain: string, + menuHandle: string, +): readonly NavItem[] { + return mapMenuItems( + readMenuItems(value, menuHandle), + "Shopify menu item", + storeDomain, + true, ); } +/** + * The footer menu's top-level items are column headings, so a heading needs + * no destination of its own — only its links do. + */ function mapFooterMenu( value: unknown, storeDomain: string, ): readonly FooterColumn[] { + return asArray( + readMenuItems(value, FOOTER_MENU_HANDLE), + "Shopify footer columns", + ).map((item, index) => { + const context = `Shopify footer column ${index}`; + const record = asRecord(item, context); + return { + heading: asText(record.title, `${context} title`), + links: mapMenuItems(record.items, `${context} link`, storeDomain, false), + }; + }); +} + +function mapCollectionImage( + value: unknown, + title: string, + context: string, +): StorefrontImage | null { if (value === null || value === undefined) { - fail(`Shopify menu "${FOOTER_MENU_HANDLE}" is missing.`); + return null; } - const menu = asRecord(value, `Shopify menu "${FOOTER_MENU_HANDLE}"`); - if (menu.handle !== FOOTER_MENU_HANDLE) { - fail("Shopify returned the wrong footer menu handle."); + const record = asRecord(value, `${context} image`); + const src = asText(record.url, `${context} image url`); + /* Next Image only serves the owned CDN tenant; anything else would crash + * the render, and the hero already handles a collection with no image. */ + if (!isShopifyProductImageUrl(src)) { + return null; } - const items = asArray( - menu.items, - `Shopify menu "${FOOTER_MENU_HANDLE}" items`, - ); - if (items.length !== EXPECTED_FOOTER_MENU.length) { - fail( - `Shopify menu "${FOOTER_MENU_HANDLE}" must contain exactly three columns.`, - ); + const width = record.width; + const height = record.height; + if ( + !Number.isInteger(width) || + !Number.isInteger(height) || + (width as number) <= 0 || + (height as number) <= 0 + ) { + fail(`${context} image has no usable intrinsic dimensions.`); } - return EXPECTED_FOOTER_MENU.map((expected, index) => { - const mapped = mapMenuItem( - items[index], - expected, - `Shopify footer column ${index}`, - storeDomain, - ); - if (mapped.children === undefined) { - fail(`Shopify footer column ${index} must contain navigation links.`); - } - return { heading: mapped.label, links: mapped.children }; - }); + const altText = record.altText; + return { + src, + alt: typeof altText === "string" && altText.length > 0 ? altText : title, + width: width as number, + height: height as number, + }; } -function mapCollection( - value: unknown, - profile: CollectionPresentationProfile, -): Collection { - const context = `Shopify collection "${profile.handle}"`; +/** + * A collection exactly as the store publishes it. + * + * Title, image and membership are the merchant's. Description and field code + * are optional: a store that sets neither renders a collection without them + * rather than borrowing copy the theme invented. + */ +function mapCollection(value: unknown, index: number): Collection { + const context = `Shopify collection ${index}`; const record = asRecord(value, context); - if (record.handle !== profile.handle) { - fail(`${context} returned the wrong handle.`); - } - const title = asText(record.title, `${context} title`); - if (title !== profile.title) { - fail(`${context} title must be "${profile.title}".`); - } + const handle = asText(record.handle, `${context} handle`); + const title = asText(record.title, `Shopify collection "${handle}" title`); const products = asRecord(record.products, `${context} products`); - const pageInfo = asRecord(products.pageInfo, `${context} products pageInfo`); - if (pageInfo.hasNextPage !== false) { - fail(`${context} products page must be complete and unpaginated.`); - } const productHandles = asArray( products.nodes, `${context} product nodes`, - ).map((entry, index) => + ).map((entry, productIndex) => asText( - asRecord(entry, `${context} product ${index}`).handle, - `${context} product ${index} handle`, + asRecord(entry, `${context} product ${productIndex}`).handle, + `${context} product ${productIndex} handle`, ), ); - if ( - productHandles.length !== profile.productHandles.length || - productHandles.some( - (handle, index) => handle !== profile.productHandles[index], - ) - ) { - fail(`${context} product membership/order does not match the contract.`); - } + const description = record.description; + const fieldCodeRecord = record.fieldCode; + const fieldCode = + fieldCodeRecord === null || fieldCodeRecord === undefined + ? "" + : asText( + asRecord(fieldCodeRecord, `${context} field code`).value, + `${context} field code value`, + ); + return { - handle: profile.handle, + handle, title, - description: profile.description, - fieldCode: profile.fieldCode, - heroImage: profile.heroImage, + description: typeof description === "string" ? description : "", + fieldCode, + heroImage: mapCollectionImage(record.image, title, context), productHandles, }; } @@ -407,13 +252,8 @@ function mapCollections(value: unknown): readonly Collection[] { } byHandle.set(handle, node); } - return COLLECTION_PRESENTATION_PROFILES.map((profile) => { - const node = byHandle.get(profile.handle); - if (node === undefined) { - fail(`Shopify collection "${profile.handle}" is missing.`); - } - return mapCollection(node, profile); - }); + /* Every collection the store publishes, in the store's order. */ + return nodes.map((node, index) => mapCollection(node, index)); } type NavigationRootField = "menu" | "footerMenu" | "collections"; diff --git a/src/lib/storefront/shopify/navigation-query.ts b/src/lib/storefront/shopify/navigation-query.ts index 49ef2db..af4aa79 100644 --- a/src/lib/storefront/shopify/navigation-query.ts +++ b/src/lib/storefront/shopify/navigation-query.ts @@ -55,6 +55,16 @@ export const NAVIGATION_QUERY = gql(` nodes { handle title + description + image { + url + width + height + altText + } + fieldCode: metafield(namespace: "forward", key: "field_code") { + value + } products(first: $collectionProductFirst) { pageInfo { hasNextPage diff --git a/src/lib/storefront/shopify/queries.ts b/src/lib/storefront/shopify/queries.ts index 8d9192b..690fcef 100644 --- a/src/lib/storefront/shopify/queries.ts +++ b/src/lib/storefront/shopify/queries.ts @@ -25,98 +25,119 @@ export const CATALOG_PRODUCT_LIMIT = 10; export const CATALOG_VARIANT_LIMIT = 50; export const CATALOG_MEDIA_LIMIT = 50; +/** + * Every field the normalized `Product` needs, shared by the whole-catalog read + * and the per-collection read so the two cannot describe different products. + */ +export const PRODUCT_FIELDS_FRAGMENT = `#graphql + fragment ForwardProductFields on Product { + id + handle + title + description + descriptionHtml + productType + tags + options { + name + optionValues { + name + swatch { + color + } + } + } + variants(first: $variantFirst) { + pageInfo { + hasNextPage + } + nodes { + id + availableForSale + price { + amount + currencyCode + } + compareAtPrice { + amount + currencyCode + } + selectedOptions { + name + value + } + } + } + media(first: $mediaFirst) { + pageInfo { + hasNextPage + } + nodes { + __typename + ... on MediaImage { + id + alt + image { + url + width + height + altText + } + } + } + } + highlights: metafield(namespace: "forward", key: "highlights") { + type + value + } + materials: metafield(namespace: "forward", key: "materials") { + type + value + } + fieldSpecs: metafield(namespace: "forward", key: "field_specs") { + type + value + } + care: metafield(namespace: "forward", key: "care") { + type + value + } + colorwayMediaMap: metafield( + namespace: "forward" + key: "colorway_media_map" + ) { + type + value + } + } +`; + export const CATALOG_QUERY = gql(` query ForwardCatalog( $first: Int! $variantFirst: Int! $mediaFirst: Int! $query: String! + $sortKey: ProductSortKeys + $reverse: Boolean $country: CountryCode $language: LanguageCode ) @inContext(country: $country, language: $language) { - products(first: $first, query: $query) { + products( + first: $first + query: $query + sortKey: $sortKey + reverse: $reverse + ) { pageInfo { hasNextPage } nodes { - id - handle - title - description - descriptionHtml - productType - tags - options { - name - optionValues { - name - } - } - variants(first: $variantFirst) { - pageInfo { - hasNextPage - } - nodes { - id - availableForSale - price { - amount - currencyCode - } - compareAtPrice { - amount - currencyCode - } - selectedOptions { - name - value - } - } - } - media(first: $mediaFirst) { - pageInfo { - hasNextPage - } - nodes { - __typename - ... on MediaImage { - id - alt - image { - url - width - height - altText - } - } - } - } - highlights: metafield(namespace: "forward", key: "highlights") { - type - value - } - materials: metafield(namespace: "forward", key: "materials") { - type - value - } - fieldSpecs: metafield(namespace: "forward", key: "field_specs") { - type - value - } - care: metafield(namespace: "forward", key: "care") { - type - value - } - colorwayMediaMap: metafield( - namespace: "forward" - key: "colorway_media_map" - ) { - type - value - } + ...ForwardProductFields } } } + ${PRODUCT_FIELDS_FRAGMENT} `); /** Credential-validity probe used only by the opt-in live verification script. */ diff --git a/src/lib/storefront/shopify/theme-routes.ts b/src/lib/storefront/shopify/theme-routes.ts new file mode 100644 index 0000000..f179a66 --- /dev/null +++ b/src/lib/storefront/shopify/theme-routes.ts @@ -0,0 +1,27 @@ +/** + * Shopify storefront paths and the theme routes that serve them. + * + * Menus and merchant-authored content both link with Shopify's own paths. + * The theme has one journal, so every blog maps onto it. A path with no route + * here maps to `null`; the caller decides whether that is a dropped menu link + * or a rejected content link. + */ +const THEME_ROUTES: readonly [RegExp, (match: RegExpMatchArray) => string][] = [ + [/^\/$/, () => "/"], + [/^\/collections\/all$/, () => "/shop"], + [/^\/collections\/([^/]+)$/, (match) => `/shop/${match[1]}`], + [/^\/blogs\/[^/]+$/, () => "/journal"], + [/^\/blogs\/[^/]+\/([^/]+)$/, (match) => `/journal/${match[1]}`], + [/^\/(shop|journal|products|pages|policies)(\/[^/]+)?$/, (match) => match[0]], + [/^\/(search|account|cart)$/, (match) => match[0]], +]; + +export function toThemePath(path: string): string | null { + for (const [pattern, route] of THEME_ROUTES) { + const match = path.match(pattern); + if (match !== null) { + return route(match); + } + } + return null; +} diff --git a/src/lib/storefront/sort.ts b/src/lib/storefront/sort.ts new file mode 100644 index 0000000..b70ab52 --- /dev/null +++ b/src/lib/storefront/sort.ts @@ -0,0 +1,87 @@ +/** + * The sort vocabulary, expressed as Shopify sort keys. + * + * Ordering is the store's job: each option here is a key the Storefront API + * understands, so a sorted page is one the API returned in that order rather + * than an array the theme re-sorted after the fact. `featured` is the + * merchant's own order — the collection's manual order in a collection, and + * relevance across the whole catalog, which is what Shopify defines it as. + */ + +import type { + ProductCollectionSortKeys, + ProductSortKeys, +} from "@shopify/hydrogen/storefront-api-types"; + +import type { ProductSort } from "./types"; + +/** Each option's label and the key a collection and the whole catalog take. */ +const SORTS: Record< + ProductSort, + { + label: string; + collection: ProductCollectionSortKeys; + catalog: ProductSortKeys; + reverse: boolean; + } +> = { + featured: { + label: "Featured", + collection: "COLLECTION_DEFAULT", + catalog: "RELEVANCE", + reverse: false, + }, + "best-selling": { + label: "Best selling", + collection: "BEST_SELLING", + catalog: "BEST_SELLING", + reverse: false, + }, + newest: { + label: "Newest", + collection: "CREATED", + catalog: "CREATED_AT", + reverse: true, + }, + "price-asc": { + label: "Price low–high", + collection: "PRICE", + catalog: "PRICE", + reverse: false, + }, + "price-desc": { + label: "Price high–low", + collection: "PRICE", + catalog: "PRICE", + reverse: true, + }, + name: { + label: "Name A–Z", + collection: "TITLE", + catalog: "TITLE", + reverse: false, + }, +}; + +export const SORT_OPTIONS = Object.entries(SORTS).map(([value, { label }]) => ({ + value: value as ProductSort, + label, +})); + +export function parseProductSort( + value: string | null | undefined, +): ProductSort { + return typeof value === "string" && Object.hasOwn(SORTS, value) + ? (value as ProductSort) + : "featured"; +} + +/** The `ProductCollectionSortKeys` a collection read takes. */ +export function collectionSortArguments(sort: ProductSort) { + return { sortKey: SORTS[sort].collection, reverse: SORTS[sort].reverse }; +} + +/** The `ProductSortKeys` the whole-catalog read takes. */ +export function catalogSortArguments(sort: ProductSort) { + return { sortKey: SORTS[sort].catalog, reverse: SORTS[sort].reverse }; +} diff --git a/src/lib/storefront/types.ts b/src/lib/storefront/types.ts index 538c562..326bd9a 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[]; } @@ -109,9 +121,12 @@ export interface JournalArticle { plate: string; publishedAt: string; readingMinutes: number; + /** From the optional `forward.location` metafield; empty when unset. */ location: string; + /** From the optional `forward.coordinates` metafield; empty when unset. */ coordinates: string; - heroImage: StorefrontImage; + /** The article's own image, or `null` when it has none. */ + heroImage: StorefrontImage | null; body: readonly ArticleBlock[]; } @@ -183,9 +198,62 @@ export interface DemoCartSeedLine { quantity: number; } -export interface ProductListFilter { - category?: ProductCategory; - activity?: string; +/** + * One value of a storefront facet. + * + * `input` is Shopify's own `ProductFilter` JSON for this value. The theme + * never builds or interprets it — it round-trips through the URL and back into + * the query — so a facet the merchant enables later works with no code change. + */ +export interface StorefrontFilterValue { + id: string; + label: string; + /** Products remaining if this value is applied. */ + count: number; + input: string; +} + +export type StorefrontFilterType = "LIST" | "PRICE_RANGE" | "BOOLEAN"; + +/** A facet exactly as the store exposes it. */ +export interface StorefrontFilter { + id: string; + label: string; + type: StorefrontFilterType; + values: readonly StorefrontFilterValue[]; } -export type ProductSort = "featured" | "price-asc" | "price-desc" | "name"; +/** One page of a collection, with the facets the store offers for it. */ +export interface CollectionProductsPage { + products: readonly Product[]; + filters: readonly StorefrontFilter[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; +} + +/** How a route asks for a page of a collection. */ +export interface CollectionProductsQuery { + /** Opaque Shopify `ProductFilter` objects, parsed from the URL. */ + filters?: readonly unknown[]; + sort?: ProductSort; + /** Cursor paging; `before` reads backwards. */ + after?: string; + before?: string; + pageBy?: number; +} + +/** + * Sort options, each one a Shopify sort key rather than a theme invention. + * `featured` is the merchant's own collection order. + */ +export type ProductSort = + | "featured" + | "price-asc" + | "price-desc" + | "name" + | "best-selling" + | "newest"; diff --git a/src/lib/weaverse/components.ts b/src/lib/weaverse/components.ts index 749214a..c6dfdd3 100644 --- a/src/lib/weaverse/components.ts +++ b/src/lib/weaverse/components.ts @@ -14,9 +14,10 @@ * * - Header, Footer, announcement bar, and mini-cart, configured through theme * settings and never composed. - * - The collection and Shop grid behavior, Cart, and `/account/**`. - * - `index-header`, `journal-*`, `product-results`, `search-*`, and - * `policy-document`, extracted for code organization only. + * - Cart and `/account/**`. Catalog browsing is composed, but its query state + * is resolved by the route. + * - `index-header`, `journal-*`, `search-*`, and `policy-document`, extracted + * for code organization only. */ import type { WeaverseNextComponent } from "@weaverse/next"; @@ -28,9 +29,11 @@ import * as Main from "@/components/main"; import * as Paragraph from "@/components/paragraph"; import * as SectionContent from "@/components/section-content"; import * as Subheading from "@/components/subheading"; +import * as AllProducts from "@/sections/all-products"; +import * as AllProductsGrid from "@/sections/all-products/product-grid"; +import * as AllProductsToolbar from "@/sections/all-products/toolbar"; import * as ArticleBody from "@/sections/article-body"; import * as ArticleHeader from "@/sections/article-header"; -import * as CollectionGrid from "@/sections/collection-grid"; import * as CollectionHero from "@/sections/collection-hero"; import * as CollectionIndex from "@/sections/collection-index"; import * as EditorialCallout from "@/sections/editorial-callout"; @@ -42,6 +45,11 @@ import * as HeroSlideshow from "@/sections/hero-slideshow"; import * as HeroSlide from "@/sections/hero-slideshow/slide"; import * as HomeHero from "@/sections/home-hero"; import * as KitCallout from "@/sections/kit-callout"; +import * as MainCollection from "@/sections/main-collection"; +import * as CollectionContent from "@/sections/main-collection/content"; +import * as CollectionFilters from "@/sections/main-collection/filters"; +import * as CollectionProductGrid from "@/sections/main-collection/product-grid"; +import * as CollectionToolbar from "@/sections/main-collection/toolbar"; import * as MainProduct from "@/sections/main-product"; import * as ProductBreadcrumb from "@/sections/main-product/breadcrumb"; import * as ProductBuyButtons from "@/sections/main-product/buy-buttons"; @@ -128,9 +136,18 @@ export const WEAVERSE_COMPONENTS: WeaverseNextComponent[] = [ /* COLLECTION */ entry(CollectionHero), entry(SystemManifest), - entry(CollectionGrid), + entry(MainCollection), + entry(CollectionToolbar), + entry(CollectionContent), + entry(CollectionFilters), + entry(CollectionProductGrid), entry(FieldPractice), + /* ALL_PRODUCTS */ + entry(AllProducts), + entry(AllProductsToolbar), + entry(AllProductsGrid), + /* ARTICLE */ entry(ArticleHeader), entry(ArticleBody), diff --git a/src/lib/weaverse/data-context.tsx b/src/lib/weaverse/data-context.tsx index 1de8640..70ee68c 100644 --- a/src/lib/weaverse/data-context.tsx +++ b/src/lib/weaverse/data-context.tsx @@ -4,11 +4,30 @@ import { createContext, type ReactNode, useContext } from "react"; import type { Collection, + CollectionProductsPage, JournalArticle, Product, + ProductSort, + StorefrontFilter, StorePage, } from "@/lib/storefront/types"; +/** + * The resolved browse state of a catalog page. + * + * Filtering, ordering and paging are query state, and query state belongs to + * the route: it is what a shopper bookmarks and shares, it is untrusted until + * validated, and it selects which products are read on the server. The facets + * are the store's own — the theme declares none — so a section renders + * whatever the store offered and never decides what a filter means. + */ +export interface CatalogBrowse { + /** Facets exactly as the store exposed them for this result. */ + filters: readonly StorefrontFilter[]; + sort: ProductSort; + pageInfo: CollectionProductsPage["pageInfo"]; +} + /** * Storefront data the route supplies to a composed page. * @@ -25,7 +44,9 @@ import type { export interface StorefrontDataContext { article?: JournalArticle; collection?: Collection; + /** The page of products the route resolved, already narrowed and ordered. */ collectionProducts?: readonly Product[]; + browse?: CatalogBrowse; page?: StorePage; product?: Product; products?: readonly Product[]; @@ -36,11 +57,8 @@ const StorefrontData = createContext({}); /** * Supplies route-loaded storefront data to the sections below it. * - * `WeaversePage` wraps the renderer in this, and a route's own fallback wraps - * the same sections in it directly. That is the point: a section reads its - * resource from one place whether Weaverse composed the page or the theme - * rendered it from its own defaults, so the credential-free storefront and the - * composed one run the same component code. + * `WeaversePage` wraps the renderer in this, so every section reads its + * resource from one place whatever template it was composed into. */ export function StorefrontDataProvider({ children, diff --git a/src/lib/weaverse/page-payload.ts b/src/lib/weaverse/page-payload.ts deleted file mode 100644 index 61a3288..0000000 --- a/src/lib/weaverse/page-payload.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { WeaverseNextLoaderData } from "@weaverse/next"; - -/** - * Whether a Weaverse payload carries content worth rendering. - * - * The Builder answers a request for an uncomposed route in more than one way: - * a fallback placeholder, the project's shared default template, or a page a - * merchant emptied. All three arrive as a real payload with a real page id, so - * matching on the id is unreliable — that is exactly how an earlier check - * passed a default template through and rendered a blank page. - * - * What they have in common is structural: the root item exists, and nothing - * hangs under it. So judge the content, not the metadata. - * - * Kept free of `server-only` and of `next/headers` so it stays directly - * testable; `server.ts` owns the parts that touch the request. - */ -export function hasAuthoredSections( - page: WeaverseNextLoaderData | null | undefined, -): boolean { - const items = page?.page?.items; - if (!Array.isArray(items) || items.length === 0) { - return false; - } - - return items.some((item) => { - const children = (item as { children?: unknown }).children; - return Array.isArray(children) && children.length > 0; - }); -} - -/** Whether a composed page already places a given component type. */ -export function pageRenders( - page: WeaverseNextLoaderData | null | undefined, - type: string, -): boolean { - const items = page?.page?.items; - if (!Array.isArray(items)) { - return false; - } - return items.some((item) => (item as { type?: unknown }).type === type); -} diff --git a/src/lib/weaverse/request-info.ts b/src/lib/weaverse/request-info.ts index f36a081..4db32b0 100644 --- a/src/lib/weaverse/request-info.ts +++ b/src/lib/weaverse/request-info.ts @@ -7,6 +7,7 @@ export type WeaversePageType = | "INDEX" | "PRODUCT" | "COLLECTION" + | "ALL_PRODUCTS" | "ARTICLE" | "PAGE" | "CUSTOM"; diff --git a/src/lib/weaverse/section-schemas.ts b/src/lib/weaverse/section-schemas.ts index 53ab619..5316415 100644 --- a/src/lib/weaverse/section-schemas.ts +++ b/src/lib/weaverse/section-schemas.ts @@ -19,9 +19,11 @@ import { schema as main } from "@/components/main/schema"; import { schema as paragraph } from "@/components/paragraph/schema"; import { schema as sectionContent } from "@/components/section-content/schema"; import { schema as subheading } from "@/components/subheading/schema"; +import { schema as allProductsGrid } from "@/sections/all-products/product-grid/schema"; +import { schema as allProducts } from "@/sections/all-products/schema"; +import { schema as allProductsToolbar } from "@/sections/all-products/toolbar/schema"; import { schema as articleBody } from "@/sections/article-body/schema"; import { schema as articleHeader } from "@/sections/article-header/schema"; -import { schema as collectionGrid } from "@/sections/collection-grid/schema"; import { schema as collectionHero } from "@/sections/collection-hero/schema"; import { schema as collectionIndex } from "@/sections/collection-index/schema"; import { schema as editorialCallout } from "@/sections/editorial-callout/schema"; @@ -33,6 +35,11 @@ import { schema as heroSlideshow } from "@/sections/hero-slideshow/schema"; import { schema as heroSlide } from "@/sections/hero-slideshow/slide/schema"; import { schema as homeHero } from "@/sections/home-hero/schema"; import { schema as kitCallout } from "@/sections/kit-callout/schema"; +import { schema as collectionContent } from "@/sections/main-collection/content/schema"; +import { schema as collectionFilters } from "@/sections/main-collection/filters/schema"; +import { schema as collectionProductGrid } from "@/sections/main-collection/product-grid/schema"; +import { schema as mainCollection } from "@/sections/main-collection/schema"; +import { schema as collectionToolbar } from "@/sections/main-collection/toolbar/schema"; import { schema as productBreadcrumb } from "@/sections/main-product/breadcrumb/schema"; import { schema as productBuyButtons } from "@/sections/main-product/buy-buttons/schema"; import { schema as productCollapsibleDetails } from "@/sections/main-product/collapsible-details/schema"; @@ -101,9 +108,18 @@ export const SECTION_SCHEMAS: readonly SchemaType[] = [ /* COLLECTION */ collectionHero, systemManifest, - collectionGrid, + mainCollection, + collectionToolbar, + collectionContent, + collectionFilters, + collectionProductGrid, fieldPractice, + /* ALL_PRODUCTS */ + allProducts, + allProductsToolbar, + allProductsGrid, + /* ARTICLE */ articleHeader, articleBody, diff --git a/src/lib/weaverse/server.ts b/src/lib/weaverse/server.ts index fbb4a92..aa1b732 100644 --- a/src/lib/weaverse/server.ts +++ b/src/lib/weaverse/server.ts @@ -7,11 +7,9 @@ * only here. A section receives both as ordinary props, so neither seam can * reach into the other. * - * Every read fails soft. Weaverse is a composition layer over a storefront - * that already renders without it, so an unconfigured project, a network - * failure, or a missing page yields `null` and the route keeps its existing - * theme-owned rendering. Composition never turns a working page into an error - * page. + * Every read fails soft: an unconfigured project, a network failure, or a + * missing page yields `null`, and the route answers 404. A page that exists + * renders exactly as authored — an empty template renders empty. */ import "server-only"; @@ -28,7 +26,6 @@ import { createWeaverseNextServerClient } from "@weaverse/next/server"; import { headers } from "next/headers"; import { cache } from "react"; import { readWeaverseConfig } from "./env"; -import { hasAuthoredSections } from "./page-payload"; import { buildRequestContext, type SearchParams, @@ -55,11 +52,6 @@ export interface LoadWeaversePageOptions { searchParams?: SearchParams; } -/** Whether this request is Studio composing the page rather than a visitor. */ -function isDesignMode(searchParams: SearchParams | undefined): boolean { - return String(searchParams?.isDesignMode) === "true"; -} - /** * Builds the server client, or `null` when Weaverse is not configured. * @@ -100,8 +92,8 @@ async function createServerClient( * Loads one Weaverse page, or `null` when composition is unavailable. * * `null` is an ordinary outcome, not an error: the project may be - * unconfigured, the Builder may hold no page for this route yet, or the fetch - * may have failed. Routes fall back to their theme-owned rendering. + * unconfigured, the Builder may hold no page for this route, or the fetch may + * have failed. Routes answer it with `notFound()`. */ export async function loadWeaversePage({ handle, @@ -129,13 +121,6 @@ export async function loadWeaversePage({ ) { return null; } - /* An empty payload is the project's shared default template, or a page a - * merchant emptied. Rendering it composes a blank route, so the route - * falls back to its own sections instead — except in Studio, where that - * empty page is exactly what the merchant is about to compose. */ - if (!isDesignMode(searchParams) && !hasAuthoredSections(page)) { - return null; - } return page; } catch { return null; @@ -143,27 +128,12 @@ export async function loadWeaversePage({ } /** - * Loads published-mode theme settings once per request. - * - * Memoized with React `cache()` so the root layout and any metadata function - * share a single fetch per request. This adds no cross-request caching, so - * design-mode reads — which the SDK forces to `no-store` — stay fresh. - */ -/** - * Builds a client for the Studio revalidation handler. - * - * When the handler supplies a validated request context, the loader re-runs - * with the live page's exact route identity. Without one — an older Studio - * bridge — fall back to a bare client so the edit still resolves rather than - * failing outright. + * Builds a client for the Studio revalidation handler, re-running the loader + * with the live page's exact route identity. */ export async function revalidateServerClient( - requestContext?: WeaverseNextRequestContext, + requestContext: WeaverseNextRequestContext, ): Promise { - if (requestContext === undefined) { - return await createServerClient("/", undefined); - } - const config = readWeaverseConfig(process.env); if (config === null) { return null; @@ -185,6 +155,13 @@ export function weaverseProjectId(): string | null { return readWeaverseConfig(process.env)?.projectId ?? null; } +/** + * Loads published-mode theme settings once per request. + * + * Memoized with React `cache()` so the root layout and any metadata function + * share a single fetch per request. This adds no cross-request caching, so + * design-mode reads — which the SDK forces to `no-store` — stay fresh. + */ export const loadWeaverseThemeSettings = cache( async (): Promise => { try { diff --git a/src/sections/all-products/index.tsx b/src/sections/all-products/index.tsx new file mode 100644 index 0000000..989726c --- /dev/null +++ b/src/sections/all-products/index.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import type { ReactNode } from "react"; + +import { browseShell } from "@/lib/presentation/variants"; + +import { + elementAttributes, + type WeaverseElementProps, +} from "../weaverse-element"; + +interface AllProductsProps extends WeaverseElementProps { + children?: ReactNode; + spacing?: VariantProps["spacing"]; +} + +/** + * The whole-catalog browse block. + * + * Order and paging are query state the route resolves; this shell owns layout + * and nothing else. The toolbar is full-bleed by design, so only the header + * children are wrapped in the page container. + */ +function AllProducts({ children, spacing, ...rest }: AllProductsProps) { + return ( +
+ {children} +
+ ); +} + +export default AllProducts; + +export { schema } from "./schema"; diff --git a/src/sections/all-products/product-grid/index.tsx b/src/sections/all-products/product-grid/index.tsx new file mode 100644 index 0000000..669e3e3 --- /dev/null +++ b/src/sections/all-products/product-grid/index.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { type CatalogColumns, CatalogGrid } from "@/components/catalog-grid"; +import { emptyState, eyebrow } from "@/lib/presentation/variants"; +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import type { WeaverseElementProps } from "../../weaverse-element"; + +interface AllProductsGridProps extends WeaverseElementProps { + columns?: CatalogColumns; + emptyBody?: string; +} + +/** The catalog page the store returned, with cursor paging beneath it. */ +function AllProductsGrid({ emptyBody, ...rest }: AllProductsGridProps) { + const { products, browse } = useStorefrontContext(); + if (products === undefined) return null; + + return ( + +
+

Nothing to show

+

+ {emptyBody ?? "This store has no published products yet."} +

+
+ + } + /> + ); +} + +export default AllProductsGrid; + +export { schema } from "./schema"; diff --git a/src/sections/all-products/product-grid/schema.ts b/src/sections/all-products/product-grid/schema.ts new file mode 100644 index 0000000..ce70f09 --- /dev/null +++ b/src/sections/all-products/product-grid/schema.ts @@ -0,0 +1,36 @@ +import { createSchema } from "@weaverse/schema"; + +export const schema = createSchema({ + type: "ap--product-grid", + title: "Product grid", + limit: 1, + enabledOn: { pages: ["ALL_PRODUCTS"] }, + settings: [ + { + group: "Grid", + inputs: [ + { + type: "toggle-group", + name: "columns", + label: "Columns", + defaultValue: "3", + configs: { + options: [ + { value: "2", label: "2" }, + { value: "3", label: "3" }, + { value: "4", label: "4" }, + ], + }, + helpText: "Desktop only; the grid is always two columns on mobile.", + }, + { + type: "text", + name: "emptyBody", + label: "Empty message", + defaultValue: "This store has no published products yet.", + }, + ], + }, + ], + presets: {}, +}); diff --git a/src/sections/all-products/schema.ts b/src/sections/all-products/schema.ts new file mode 100644 index 0000000..90e239b --- /dev/null +++ b/src/sections/all-products/schema.ts @@ -0,0 +1,48 @@ +import { createSchema } from "@weaverse/schema"; + +export const ALL_PRODUCTS_CHILD_TYPES = [ + "heading", + "paragraph", + "ap--toolbar", + "ap--product-grid", +]; + +export const schema = createSchema({ + type: "all-products", + title: "All products", + limit: 1, + enabledOn: { pages: ["ALL_PRODUCTS"] }, + childTypes: ALL_PRODUCTS_CHILD_TYPES, + settings: [ + { + group: "Layout", + inputs: [ + { + type: "select", + name: "spacing", + label: "Space below", + defaultValue: "standard", + configs: { + options: [ + { value: "compact", label: "Compact" }, + { value: "standard", label: "Standard" }, + { value: "roomy", label: "Roomy" }, + ], + }, + }, + ], + }, + ], + presets: { + spacing: "standard", + children: [ + { type: "heading", content: "All products", as: "h1" }, + { + type: "paragraph", + content: "Every product this store publishes.", + }, + { type: "ap--toolbar" }, + { type: "ap--product-grid" }, + ], + }, +}); diff --git a/src/sections/all-products/toolbar/index.tsx b/src/sections/all-products/toolbar/index.tsx new file mode 100644 index 0000000..0a943cd --- /dev/null +++ b/src/sections/all-products/toolbar/index.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { + CatalogToolbar, + type CatalogToolbarSettings, +} from "@/components/catalog-toolbar"; +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import type { WeaverseElementProps } from "../../weaverse-element"; + +/** Count and order for the whole catalog; it has no facets to clear. */ +function AllProductsToolbar( + props: CatalogToolbarSettings & WeaverseElementProps, +) { + const { products, browse } = useStorefrontContext(); + if (products === undefined || browse === undefined) { + return null; + } + return ( + + ); +} + +export default AllProductsToolbar; + +export { schema } from "./schema"; diff --git a/src/sections/all-products/toolbar/schema.ts b/src/sections/all-products/toolbar/schema.ts new file mode 100644 index 0000000..1939970 --- /dev/null +++ b/src/sections/all-products/toolbar/schema.ts @@ -0,0 +1,34 @@ +import { createSchema } from "@weaverse/schema"; + +export const schema = createSchema({ + type: "ap--toolbar", + title: "Toolbar", + limit: 1, + enabledOn: { pages: ["ALL_PRODUCTS"] }, + settings: [ + { + group: "Toolbar", + inputs: [ + { + type: "switch", + name: "showCount", + label: "Show product count", + defaultValue: true, + }, + { + type: "switch", + name: "showSort", + label: "Show sort control", + defaultValue: true, + }, + { + type: "switch", + name: "sticky", + label: "Stick below the header", + defaultValue: true, + }, + ], + }, + ], + presets: {}, +}); diff --git a/src/sections/article-body/index.tsx b/src/sections/article-body/index.tsx index e1cddc5..b3d18f8 100644 --- a/src/sections/article-body/index.tsx +++ b/src/sections/article-body/index.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import Link from "next/link"; +import { Fragment } from "react"; import { eyebrow, textLink } from "@/lib/presentation/variants"; import { formatDate } from "@/lib/storefront/format"; @@ -114,11 +115,18 @@ function ArticleAside({ ); diff --git a/src/sections/article-header/index.tsx b/src/sections/article-header/index.tsx index e637e89..8700265 100644 --- a/src/sections/article-header/index.tsx +++ b/src/sections/article-header/index.tsx @@ -36,15 +36,17 @@ function ArticleHeader({ className="mx-3 mt-5.5 grid min-h-0 grid-cols-1 items-stretch bg-ink text-text-inverse md:mx-7 md:min-h-article-min md:grid-cols-page-header" >
- {article.heroImage.alt} + {article.heroImage === null ? null : ( + {article.heroImage.alt} + )}

@@ -56,7 +58,7 @@ function ArticleHeader({

{formatDate(article.publishedAt)} - {article.location} + {article.location === "" ? null : {article.location}} {article.readingMinutes} minute read
diff --git a/src/sections/collection-grid/index.tsx b/src/sections/collection-grid/index.tsx deleted file mode 100644 index 1a163bf..0000000 --- a/src/sections/collection-grid/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import Link from "next/link"; -import type { ReactNode } from "react"; - -import { ProductCard } from "@/components/product-card"; -import { cta } from "@/lib/presentation/variants"; -import { useStorefrontContext } from "@/lib/weaverse/data-context"; -import { - elementAttributes, - type WeaverseElementProps, -} from "../weaverse-element"; - -interface CollectionGridProps extends WeaverseElementProps { - children?: ReactNode; - ctaLabel: string; - ctaHref: string; -} - -/** Dark product grid for a collection, introduced by a heading and one link. */ -function CollectionGrid({ - children, - ctaLabel, - ctaHref, - ...rest -}: CollectionGridProps) { - const { collectionProducts } = useStorefrontContext(); - const products = collectionProducts ?? []; - return ( -
-
-
-
{children}
- - {ctaLabel} - -
-
- {products.map((product) => ( - - ))} -
-
-
- ); -} - -export default CollectionGrid; - -export { schema } from "./schema"; diff --git a/src/sections/collection-grid/schema.ts b/src/sections/collection-grid/schema.ts deleted file mode 100644 index 291247c..0000000 --- a/src/sections/collection-grid/schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createSchema } from "@weaverse/schema"; - -export const schema = createSchema({ - type: "collection-grid", - title: "Collection grid", - childTypes: ["section-content"], - settings: [ - { - group: "Content", - inputs: [ - { - type: "text", - name: "ctaLabel", - label: "CTA label", - }, - { - type: "url", - name: "ctaHref", - label: "CTA link", - }, - ], - }, - ], - enabledOn: { - pages: ["COLLECTION"], - }, - presets: { - children: [ - { - type: "section-content", - children: [ - { - type: "subheading", - content: "Collection essentials", - tone: "warm", - }, - { type: "heading", content: "A focused kit for a full day out." }, - ], - }, - ], - ctaLabel: "View all equipment", - ctaHref: "/shop", - }, -}); diff --git a/src/sections/collection-hero/index.tsx b/src/sections/collection-hero/index.tsx index c209d91..6d3972a 100644 --- a/src/sections/collection-hero/index.tsx +++ b/src/sections/collection-hero/index.tsx @@ -33,27 +33,34 @@ function CollectionHero({ {...elementAttributes(rest)} className="relative mx-3 mt-5.5 grid min-h-0 grid-cols-1 items-stretch overflow-hidden bg-ink text-text-inverse md:mx-7 md:min-h-page-min md:grid-cols-[1.3fr_0.7fr]" > -
- {collection.heroImage.alt} -
+ {/* A store that set no collection image gets the panel alone rather + than a broken frame. */} + {collection.heroImage === null ? null : ( +
+ {collection.heroImage.alt} +
+ )}

- {eyebrowPrefix} {collection.fieldCode} + {eyebrowPrefix} + {collection.fieldCode === "" ? "" : ` ${collection.fieldCode}`}

{collection.title}

-

- {collection.description} -

+ {collection.description === "" ? null : ( +

+ {collection.description} +

+ )} - {collection.heroImage.alt} + {collection.heroImage === null ? null : ( + {collection.heroImage.alt} + )}
{collection.fieldCode} diff --git a/src/sections/index-header.tsx b/src/sections/index-header.tsx index 5db3726..8097e80 100644 --- a/src/sections/index-header.tsx +++ b/src/sections/index-header.tsx @@ -29,9 +29,11 @@ export function IndexHeader({ {heading}
-

- {lede} -

+ {lede === "" ? null : ( +

+ {lede} +

+ )}
); diff --git a/src/sections/journal-grid.tsx b/src/sections/journal-grid.tsx index 898b59d..dbc538b 100644 --- a/src/sections/journal-grid.tsx +++ b/src/sections/journal-grid.tsx @@ -38,15 +38,17 @@ export function JournalGrid({ className="col-span-full sm:col-span-6 md:col-span-4" > - {article.heroImage.alt} + {article.heroImage === null ? null : ( + {article.heroImage.alt} + )}

{article.plate} · {article.readingMinutes} min read

diff --git a/src/sections/journal-lead.tsx b/src/sections/journal-lead.tsx index baa6e1f..fef15da 100644 --- a/src/sections/journal-lead.tsx +++ b/src/sections/journal-lead.tsx @@ -18,15 +18,17 @@ export function JournalLead({ linkLabel, article }: JournalLeadProps) { href={`/journal/${article.handle}`} >
- {article.heroImage.alt} + {article.heroImage === null ? null : ( + {article.heroImage.alt} + )}

diff --git a/src/sections/main-collection/content/index.tsx b/src/sections/main-collection/content/index.tsx new file mode 100644 index 0000000..265c207 --- /dev/null +++ b/src/sections/main-collection/content/index.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { cva } from "class-variance-authority"; +import type { ReactNode } from "react"; + +import { + elementAttributes, + type WeaverseElementProps, +} from "../../weaverse-element"; + +type Gap = "sm" | "md" | "lg"; + +/* The toolbar above is full-bleed by design, so the page container belongs to + * this row rather than the shell. */ +const row = cva( + "mx-auto flex w-full max-w-page flex-col items-start px-page-gutter pt-15.5 lg:flex-row", + { + variants: { + gap: { sm: "gap-6", md: "gap-9", lg: "gap-12" }, + }, + defaultVariants: { gap: "md" }, + }, +); + +interface CollectionContentProps extends WeaverseElementProps { + children?: ReactNode; + gap?: Gap; +} + +/** + * The results row: the facet sidebar beside the grid. + * + * Which side the sidebar sits on is the order the merchant put the children + * in, not a setting — a flex row already answers that, and a "position" + * control would fight whatever the outline says. + */ +function CollectionContent({ children, gap, ...rest }: CollectionContentProps) { + return ( +

+ {children} +
+ ); +} + +export default CollectionContent; + +export { schema } from "./schema"; diff --git a/src/sections/main-collection/content/schema.ts b/src/sections/main-collection/content/schema.ts new file mode 100644 index 0000000..d6a8b1a --- /dev/null +++ b/src/sections/main-collection/content/schema.ts @@ -0,0 +1,38 @@ +import { createSchema } from "@weaverse/schema"; + +/** The results row's elements, in their default order. */ +export const COLLECTION_CONTENT_CHILD_TYPES = [ + "mc--filters", + "mc--product-grid", +]; + +export const schema = createSchema({ + type: "mc--content", + title: "Collection content", + limit: 1, + enabledOn: { pages: ["COLLECTION"] }, + childTypes: COLLECTION_CONTENT_CHILD_TYPES, + settings: [ + { + group: "Layout", + inputs: [ + { + type: "toggle-group", + name: "gap", + label: "Column gap", + defaultValue: "md", + configs: { + options: [ + { value: "sm", label: "Small" }, + { value: "md", label: "Medium" }, + { value: "lg", label: "Large" }, + ], + }, + }, + ], + }, + ], + presets: { + children: COLLECTION_CONTENT_CHILD_TYPES.map((type) => ({ type })), + }, +}); diff --git a/src/sections/main-collection/filters/index.tsx b/src/sections/main-collection/filters/index.tsx new file mode 100644 index 0000000..af0b05f --- /dev/null +++ b/src/sections/main-collection/filters/index.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; + +import { FacetList } from "@/components/facet-list"; +import { cn } from "@/lib/cn"; +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import { + elementAttributes, + type WeaverseElementProps, +} from "../../weaverse-element"; + +const DEFAULT_SIDEBAR_WIDTH = 288; + +interface CollectionFiltersProps extends WeaverseElementProps { + heading?: string; + sidebarWidth?: number; + showCounts?: boolean; + sticky?: boolean; +} + +/** + * The facet controls, for mobile and desktop. + * + * Whatever the store returned is what renders — enable a filter in Search & + * Discovery and it appears here with no theme change. Nothing is shown when + * the store exposes no facets for this collection. + */ +function CollectionFilters({ + heading, + sidebarWidth, + showCounts, + sticky, + ...rest +}: CollectionFiltersProps) { + const { browse } = useStorefrontContext(); + const pathname = usePathname(); + const params = useSearchParams(); + if (browse === undefined || browse.filters.length === 0) { + return null; + } + const facets = (idPrefix: string) => ( + + ); + + return ( +
+
+ + {heading ?? "Filters"} + + {facets("collection-mobile")} +
+
+

+ {heading ?? "Filters"} +

+ {facets("collection-desktop")} +
+
+ ); +} + +export default CollectionFilters; + +export { schema } from "./schema"; diff --git a/src/sections/main-collection/filters/schema.ts b/src/sections/main-collection/filters/schema.ts new file mode 100644 index 0000000..e0d653d --- /dev/null +++ b/src/sections/main-collection/filters/schema.ts @@ -0,0 +1,42 @@ +import { createSchema } from "@weaverse/schema"; + +export const schema = createSchema({ + type: "mc--filters", + title: "Collection filters", + limit: 1, + enabledOn: { pages: ["COLLECTION"] }, + settings: [ + { + group: "Filters", + inputs: [ + { + type: "text", + name: "heading", + label: "Heading", + defaultValue: "Filters", + }, + { + type: "range", + name: "sidebarWidth", + label: "Sidebar width", + defaultValue: 288, + configs: { min: 200, max: 400, step: 8, unit: "px" }, + helpText: "Desktop only; mobile filters use the full row width.", + }, + { + type: "switch", + name: "showCounts", + label: "Show match counts", + defaultValue: true, + }, + { + type: "switch", + name: "sticky", + label: "Stick while the grid scrolls", + defaultValue: true, + }, + ], + }, + ], + presets: {}, +}); diff --git a/src/sections/main-collection/index.tsx b/src/sections/main-collection/index.tsx new file mode 100644 index 0000000..e5a3bee --- /dev/null +++ b/src/sections/main-collection/index.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import type { ReactNode } from "react"; + +import { browseShell } from "@/lib/presentation/variants"; + +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import { + elementAttributes, + type WeaverseElementProps, +} from "../weaverse-element"; + +interface MainCollectionProps extends WeaverseElementProps { + children?: ReactNode; + spacing?: VariantProps["spacing"]; +} + +/** + * The collection browse block, composed from `mc--toolbar` and `mc--content`. + * + * The shell owns layout and nothing else. Filter and sort are query state the + * route resolves and validates before any product is read, and it arrives here + * through the storefront data context — so a merchant reordering this tree can + * never change what a filter means or which products a URL selects. + */ +function MainCollection({ children, spacing, ...rest }: MainCollectionProps) { + const { collection } = useStorefrontContext(); + if (collection === undefined) return null; + + return ( +
+ {children} +
+ ); +} + +export default MainCollection; + +export { schema } from "./schema"; diff --git a/src/sections/main-collection/product-grid/index.tsx b/src/sections/main-collection/product-grid/index.tsx new file mode 100644 index 0000000..9c9a3bb --- /dev/null +++ b/src/sections/main-collection/product-grid/index.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; + +import { type CatalogColumns, CatalogGrid } from "@/components/catalog-grid"; +import { cta, emptyState, eyebrow } from "@/lib/presentation/variants"; +import { + clearFiltersHref, + hasAppliedFilters, +} from "@/lib/storefront/filter-params"; +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import type { WeaverseElementProps } from "../../weaverse-element"; + +interface CollectionProductGridProps extends WeaverseElementProps { + columns?: CatalogColumns; + emptyBody?: string; +} + +/** The page of results the store returned, or an honest empty state. */ +function CollectionProductGrid({ + emptyBody, + ...rest +}: CollectionProductGridProps) { + const { collectionProducts, browse } = useStorefrontContext(); + const pathname = usePathname(); + const params = useSearchParams(); + if (collectionProducts === undefined) return null; + + return ( + +
+

No matching products

+

+ {emptyBody ?? "Nothing here matches that filter."} +

+ {/* Clearing drops the facet params only, so a campaign tag or the + Studio design-mode query on the URL survives the reset. */} + {hasAppliedFilters(params) ? ( + + Clear filters + + ) : null} +
+
+ } + /> + ); +} + +export default CollectionProductGrid; + +export { schema } from "./schema"; diff --git a/src/sections/main-collection/product-grid/schema.ts b/src/sections/main-collection/product-grid/schema.ts new file mode 100644 index 0000000..a4f5158 --- /dev/null +++ b/src/sections/main-collection/product-grid/schema.ts @@ -0,0 +1,41 @@ +import { createSchema } from "@weaverse/schema"; + +export const schema = createSchema({ + type: "mc--product-grid", + title: "Collection product grid", + limit: 1, + enabledOn: { pages: ["COLLECTION"] }, + settings: [ + { + group: "Grid", + inputs: [ + { + type: "toggle-group", + name: "columns", + label: "Columns", + defaultValue: "3", + configs: { + options: [ + { value: "2", label: "2" }, + { value: "3", label: "3" }, + { value: "4", label: "4" }, + ], + }, + helpText: "Desktop only; the grid is always two columns on mobile.", + }, + ], + }, + { + group: "Empty state", + inputs: [ + { + type: "text", + name: "emptyBody", + label: "Message", + defaultValue: "Nothing here matches that filter.", + }, + ], + }, + ], + presets: {}, +}); diff --git a/src/sections/main-collection/schema.ts b/src/sections/main-collection/schema.ts new file mode 100644 index 0000000..663049d --- /dev/null +++ b/src/sections/main-collection/schema.ts @@ -0,0 +1,41 @@ +import { createSchema } from "@weaverse/schema"; + +import { COLLECTION_CONTENT_CHILD_TYPES } from "./content/schema"; + +export const schema = createSchema({ + type: "main-collection", + title: "Main collection", + limit: 1, + enabledOn: { pages: ["COLLECTION"] }, + childTypes: ["mc--toolbar", "mc--content"], + settings: [ + { + group: "Layout", + inputs: [ + { + type: "select", + name: "spacing", + label: "Space below", + defaultValue: "standard", + configs: { + options: [ + { value: "compact", label: "Compact" }, + { value: "standard", label: "Standard" }, + { value: "roomy", label: "Roomy" }, + ], + }, + }, + ], + }, + ], + presets: { + spacing: "standard", + children: [ + { type: "mc--toolbar" }, + { + type: "mc--content", + children: COLLECTION_CONTENT_CHILD_TYPES.map((type) => ({ type })), + }, + ], + }, +}); diff --git a/src/sections/main-collection/toolbar/index.tsx b/src/sections/main-collection/toolbar/index.tsx new file mode 100644 index 0000000..fc6f01b --- /dev/null +++ b/src/sections/main-collection/toolbar/index.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { + CatalogToolbar, + type CatalogToolbarSettings, +} from "@/components/catalog-toolbar"; +import { useStorefrontContext } from "@/lib/weaverse/data-context"; + +import type { WeaverseElementProps } from "../../weaverse-element"; + +/** Count, clear filters and order for the collection the route resolved. */ +function CollectionToolbar( + props: CatalogToolbarSettings & WeaverseElementProps, +) { + const { collectionProducts, browse } = useStorefrontContext(); + if (collectionProducts === undefined || browse === undefined) { + return null; + } + return ( + + ); +} + +export default CollectionToolbar; + +export { schema } from "./schema"; diff --git a/src/sections/main-collection/toolbar/schema.ts b/src/sections/main-collection/toolbar/schema.ts new file mode 100644 index 0000000..ff8448e --- /dev/null +++ b/src/sections/main-collection/toolbar/schema.ts @@ -0,0 +1,36 @@ +import { createSchema } from "@weaverse/schema"; + +export const schema = createSchema({ + type: "mc--toolbar", + title: "Collection toolbar", + limit: 1, + enabledOn: { pages: ["COLLECTION"] }, + settings: [ + { + group: "Toolbar", + inputs: [ + { + type: "switch", + name: "showCount", + label: "Show product count", + defaultValue: true, + }, + { + type: "switch", + name: "showSort", + label: "Show sort control", + defaultValue: true, + }, + { + type: "switch", + name: "sticky", + label: "Stick below the header", + defaultValue: true, + helpText: + "Keeps the count and sort reachable while the grid scrolls.", + }, + ], + }, + ], + presets: {}, +}); diff --git a/src/sections/main-product/context.ts b/src/sections/main-product/context.ts index 5f8011b..448163d 100644 --- a/src/sections/main-product/context.ts +++ b/src/sections/main-product/context.ts @@ -2,12 +2,8 @@ import { createContext, useContext } from "react"; -import { - type ProductSelection, - resolveProductSelection, -} from "@/lib/storefront/product-state"; +import type { ProductSelection } from "@/lib/storefront/product-state"; import type { Product } from "@/lib/storefront/types"; -import { useStorefrontContext } from "@/lib/weaverse/data-context"; export type GalleryPosition = "left" | "right"; @@ -22,22 +18,9 @@ export interface MainProductState { export const MainProductContext = createContext(null); /** - * What every `mp--*` child renders from. - * - * Inside `main-product` that is the URL-resolved selection. A child rendered - * on its own — dropped outside the section in Studio, or mounted by a test — - * falls back to the route's product at its default selection, and to nothing - * when the page has no product. + * What every `mp--*` child renders from: the URL-resolved selection its + * `main-product` shell provides. A child outside that shell renders nothing. */ export function useMainProduct(): MainProductState | null { - const state = useContext(MainProductContext); - const { product } = useStorefrontContext(); - if (state !== null) return state; - if (product === undefined) return null; - return { - product, - selection: resolveProductSelection(product, undefined), - currentParams: new URLSearchParams(), - galleryPosition: "right", - }; + return useContext(MainProductContext); } diff --git a/src/sections/main-product/index.tsx b/src/sections/main-product/index.tsx index f72fae0..299f969 100644 --- a/src/sections/main-product/index.tsx +++ b/src/sections/main-product/index.tsx @@ -2,7 +2,7 @@ import { cva } from "class-variance-authority"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Children, type ReactNode, Suspense, useEffect } from "react"; +import { type ReactNode, Suspense, useEffect } from "react"; import { COLORWAY_PARAM, @@ -17,17 +17,7 @@ import { elementAttributes, type WeaverseElementProps, } from "../weaverse-element"; -import ProductBreadcrumb from "./breadcrumb"; -import ProductBuyButtons from "./buy-buttons"; -import ProductCollapsibleDetails from "./collapsible-details"; import { type GalleryPosition, MainProductContext } from "./context"; -import ProductInfo from "./info"; -import ProductMedia from "./media"; -import ProductMeta from "./meta"; -import ProductPrices from "./prices"; -import ProductSummary from "./summary"; -import ProductTitle from "./title"; -import ProductVariantSelector from "./variant-selector"; type PanelWidth = "standard" | "wide"; @@ -71,10 +61,6 @@ interface MainProductProps extends WeaverseElementProps { * The shell owns what its children share: the `colorway`/option query state, * resolved into one selection every child reads through `MainProductContext`, * and the two-column grid they sit in. Cart logic stays in `AddToCartForm`. - * - * A product URL must never lose its gallery, selection and add to cart, so a - * section with no children — the route's own fallback, or a template seeded - * before the block was split — renders the default composition. */ function MainProduct({ children, @@ -85,8 +71,6 @@ function MainProduct({ const { product } = useStorefrontContext(); if (product === undefined) return null; const position = galleryPosition ?? "right"; - const content = - Children.count(children) > 0 ? children : ; return (
- {content} + {children} } > - {content} + {children}
@@ -176,25 +160,6 @@ function UrlSelection({ ); } -/** What the section renders when it has no children of its own. */ -function DefaultComposition() { - return ( - <> - - - - - - - - - - - - - ); -} - export default MainProduct; export { schema } from "./schema"; 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({