From a955123d520e5627b063f604d92d20800f1d2c90 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 6 Aug 2026 22:07:48 +0100 Subject: [PATCH 01/29] Refresh React Router Oxygen template Assisted-By: devx/bf421943-f843-4961-b8a8-dda9832dc219 --- skills/create-oxygen-template/SKILL.md | 99 +- .../reference/react-router-pattern.md | 82 +- templates/react-router/.env.example | 29 +- templates/react-router/.gitignore | 10 +- templates/react-router/LICENSE | 20 +- templates/react-router/README.md | 120 +- templates/react-router/app/app.css | 1030 ++- .../app/components/AnalyticsTracker.tsx | 30 + .../app/components/AnalyticsTrackers.tsx | 141 - .../app/components/Breadcrumbs.tsx | 51 + .../app/components/CartAnalyticsTracker.tsx | 10 + .../app/components/CartContent.tsx | 179 + .../app/components/CartDrawer.tsx | 342 +- .../app/components/CartLineItem.tsx | 144 + .../app/components/CollectionBrowse.tsx | 710 --- .../app/components/CollectionCard.tsx | 126 +- .../app/components/ConsentBanner.tsx | 163 +- .../react-router/app/components/Footer.tsx | 53 +- .../react-router/app/components/Header.tsx | 289 +- .../react-router/app/components/MobileNav.tsx | 77 - .../react-router/app/components/NotFound.tsx | 25 + .../app/components/PredictiveSearchModal.tsx | 212 + .../app/components/ProductCard.tsx | 140 +- .../app/components/QuantityStepper.tsx | 82 + templates/react-router/app/lib/analytics.ts | 46 +- templates/react-router/app/lib/cart-drawer.ts | 84 +- .../react-router/app/lib/cart-handlers.ts | 36 +- templates/react-router/app/lib/cart.ts | 8 +- templates/react-router/app/lib/collection.ts | 115 - templates/react-router/app/lib/collections.ts | 49 - templates/react-router/app/lib/config.ts | 82 + templates/react-router/app/lib/content.ts | 150 + .../react-router/app/lib/customer-account.ts | 58 + .../app/lib/customer-session-handlers.ts | 13 + .../react-router/app/lib/customer-session.ts | 192 + templates/react-router/app/lib/env.ts | 10 - templates/react-router/app/lib/filters.tsx | 227 + templates/react-router/app/lib/fragments.ts | 120 + templates/react-router/app/lib/image.ts | 61 + templates/react-router/app/lib/meta.ts | 22 + templates/react-router/app/lib/money.ts | 25 +- templates/react-router/app/lib/platform.ts | 22 + .../app/lib/predictive-search-handlers.ts | 11 + .../react-router/app/lib/product-query.ts | 111 + templates/react-router/app/lib/product.ts | 13 +- .../react-router/app/lib/route-templates.ts | 3 + templates/react-router/app/lib/search.ts | 162 - templates/react-router/app/lib/session.ts | 15 - templates/react-router/app/lib/shop.ts | 85 - templates/react-router/app/lib/site.ts | 9 + .../app/lib/storefront-context.ts | 15 + .../app/lib/storefront-middleware.ts | 181 + templates/react-router/app/lib/storefront.ts | 55 - templates/react-router/app/root.tsx | 219 +- templates/react-router/app/routes.ts | 10 +- templates/react-router/app/routes/account.tsx | 225 + templates/react-router/app/routes/cart.tsx | 102 +- .../react-router/app/routes/catchall.tsx | 32 +- .../react-router/app/routes/collection.tsx | 680 +- .../react-router/app/routes/collections.tsx | 171 +- templates/react-router/app/routes/home.tsx | 212 +- templates/react-router/app/routes/product.tsx | 1096 ++-- templates/react-router/app/routes/robots.tsx | 15 + templates/react-router/app/routes/search.tsx | 566 +- templates/react-router/app/routes/sitemap.tsx | 86 + .../react-router/app/standard-actions.d.ts | 181 - templates/react-router/app/tokens.css | 893 --- templates/react-router/env.d.ts | 12 +- templates/react-router/package-lock.json | 5495 +++++++++++++++++ templates/react-router/package.json | 9 +- templates/react-router/public/favicon.ico | Bin 15086 -> 0 bytes templates/react-router/public/favicon.svg | 11 +- templates/react-router/react-router.config.ts | 3 +- templates/react-router/server.ts | 18 +- templates/react-router/tsconfig.json | 37 +- templates/react-router/vite.config.ts | 12 - 76 files changed, 11165 insertions(+), 5064 deletions(-) create mode 100644 templates/react-router/app/components/AnalyticsTracker.tsx delete mode 100644 templates/react-router/app/components/AnalyticsTrackers.tsx create mode 100644 templates/react-router/app/components/Breadcrumbs.tsx create mode 100644 templates/react-router/app/components/CartAnalyticsTracker.tsx create mode 100644 templates/react-router/app/components/CartContent.tsx create mode 100644 templates/react-router/app/components/CartLineItem.tsx delete mode 100644 templates/react-router/app/components/CollectionBrowse.tsx delete mode 100644 templates/react-router/app/components/MobileNav.tsx create mode 100644 templates/react-router/app/components/NotFound.tsx create mode 100644 templates/react-router/app/components/PredictiveSearchModal.tsx create mode 100644 templates/react-router/app/components/QuantityStepper.tsx delete mode 100644 templates/react-router/app/lib/collection.ts delete mode 100644 templates/react-router/app/lib/collections.ts create mode 100644 templates/react-router/app/lib/config.ts create mode 100644 templates/react-router/app/lib/content.ts create mode 100644 templates/react-router/app/lib/customer-account.ts create mode 100644 templates/react-router/app/lib/customer-session-handlers.ts create mode 100644 templates/react-router/app/lib/customer-session.ts delete mode 100644 templates/react-router/app/lib/env.ts create mode 100644 templates/react-router/app/lib/filters.tsx create mode 100644 templates/react-router/app/lib/fragments.ts create mode 100644 templates/react-router/app/lib/image.ts create mode 100644 templates/react-router/app/lib/meta.ts create mode 100644 templates/react-router/app/lib/platform.ts create mode 100644 templates/react-router/app/lib/predictive-search-handlers.ts create mode 100644 templates/react-router/app/lib/product-query.ts delete mode 100644 templates/react-router/app/lib/search.ts delete mode 100644 templates/react-router/app/lib/session.ts delete mode 100644 templates/react-router/app/lib/shop.ts create mode 100644 templates/react-router/app/lib/site.ts create mode 100644 templates/react-router/app/lib/storefront-context.ts create mode 100644 templates/react-router/app/lib/storefront-middleware.ts delete mode 100644 templates/react-router/app/lib/storefront.ts create mode 100644 templates/react-router/app/routes/account.tsx create mode 100644 templates/react-router/app/routes/robots.tsx create mode 100644 templates/react-router/app/routes/sitemap.tsx delete mode 100644 templates/react-router/app/standard-actions.d.ts delete mode 100644 templates/react-router/app/tokens.css create mode 100644 templates/react-router/package-lock.json delete mode 100644 templates/react-router/public/favicon.ico diff --git a/skills/create-oxygen-template/SKILL.md b/skills/create-oxygen-template/SKILL.md index 3c9afd4283..81667ffd84 100644 --- a/skills/create-oxygen-template/SKILL.md +++ b/skills/create-oxygen-template/SKILL.md @@ -1,29 +1,29 @@ --- name: create-oxygen-template description: > - Create, upgrade, or maintain the canonical source for the Oxygen-ready React Router template under templates/react-router. - Use for a professional React Router starter with MiniOxygen/Vite dev setup, a Worker server entrypoint, env-driven - configuration, Oxygen cache wiring, and no monorepo-only app imports or development plugins. + Convert the React Router example into a standalone Oxygen-ready template under templates/react-router, or repeat + that React Router-specific workflow for closely related examples. Use when asked to create, upgrade, or maintain a + professional React Router starter with MiniOxygen/Vite dev setup, a Worker server entrypoint, env-driven + configuration, Oxygen cache wiring, and removal of example-only shared or development dependencies. --- # Create Oxygen Template ## Goal -Maintain `templates/react-router` as the canonical source for a professional starter that runs on Oxygen/MiniOxygen through Vite. Keep the app decoupled from monorepo-only shared code and development-only plugins while using the workspace Hydrogen package for local integration coverage. +Create a standalone React Router template from `examples/react-router`. The template must be a professional starter that runs on Oxygen/MiniOxygen through Vite, keeps the example's app behavior, and does not rely on monorepo-only shared code or development-only plugins. ## Workflow -1. Work directly in `templates/react-router`; it is the source of truth for the starter. - Keep generated and local artifacts out of the source template: - - `node_modules/` - - `.react-router/`, `build/`, `dist/` - - `.env` (the template ships `.env.example` and a gitignored `.env`) - - `*-graphql-env.d.ts` - - package-manager lockfiles (ignored by the repository root) - Keep lockfile ignores at the repository root rather than in the template's own `.gitignore`, so the distributed starter can commit its generated lockfile. +1. Copy `examples/react-router` to `templates/react-router` unless the user names a closely related React Router source. + Do NOT copy build/generated/local artifacts — exclude them at copy time (e.g. `rsync -a --exclude ...`): + - `node_modules/` (huge; reinstall in the template instead) + - `.react-router/`, `build/`, `dist/` (build output) + - `.env` (may contain a real decrypted secret — the template ships `.env.example` + a fresh gitignored `.env`) + - `*-graphql-env.d.ts` (gql.tada generated types; regenerated by typecheck) + Then ensure the template `.gitignore` covers all of the above. 2. Preserve app features and route behavior unless the user explicitly asks to simplify. -3. Remove monorepo-only coupling: +3. Remove example-only coupling: - no `@shared/*` imports - no `examples/shared/*` runtime dependency - no `localCdnAssets` @@ -33,16 +33,16 @@ Maintain `templates/react-router` as the canonical source for a professional sta - no `@react-router/node`, `@react-router/serve`, or `react-router-serve` unless the template intentionally supports a Node server path - no `lru-cache` for Hydrogen primitives on Oxygen - no `catalog:` dependency ranges in the final template package - - use `@shopify/hydrogen: workspace:*` in this repository so template E2E exercises the package under development - (see "Hydrogen dependency" below). Do not use repo-local `file:` dependencies or vendored package tarballs. + - use `@shopify/hydrogen: preview` (see "Hydrogen dependency" below). Do not use `workspace:*`, repo-local `file:` + dependencies, vendored `shopify-hydrogen-*.tgz` tarballs, or classic Hydrogen semver ranges. -Keep `lib/route-templates.ts` unchanged. It defines `routeTemplates` via `createShopifyRouteTemplates`, which is a REQUIRED arg on `handleShopifyRedirects`, `ShopifyScripts` (`routes` prop), and `getPredictiveSearchItemUrl` (`routes` option). +Keep `lib/route-templates.ts` unchanged — it is NOT example-only coupling. It defines `routeTemplates` via `createShopifyRouteTemplates`, which is now a REQUIRED arg on `handleShopifyRedirects`, `ShopifyScripts` (`routes` prop), and `getPredictiveSearchItemUrl` (`routes` option). Carry the file over as-is. 4. Add Oxygen/MiniOxygen support: - `@shopify/mini-oxygen`: pin `^4.2.0` — its `oxygen()` plugin adds `configurePreviewServer`, which `vite preview` needs to run the Worker. - `@shopify/oxygen-workers-types` - - `@shopify/cli` only for the deploy script. Pin `4.6.0` (minimum `4.4.0`) because deploy must support the explicit `--assets-dir` and `--worker-dir` flags. + - `@shopify/cli` only for the deploy script (pin `3.94.3` or newer only after verifying deploy behavior) - a Worker entrypoint, usually root `server.ts` - plain `oxygen()` in `vite.config.ts`. The plugin auto-loads `.env` into the Worker via its own `loadEnv` fallback when no env is provided (MiniOxygen >= 4.2.0). @@ -64,48 +64,48 @@ Implementation details (exact per-file shape) live in [reference/react-router-pa - **Use `CI=true` for installs** in this repo (installs abort without a TTY: `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), and `--no-frozen-lockfile` on the first install after adding the template or changing dependencies. -- **Build the local Hydrogen package first**: `pnpm --filter @shopify/hydrogen build`. The source template consumes - `@shopify/hydrogen: workspace:*`, so its runtime imports, packed TypeScript plugin, and schemas use the package built in this repository. +- Building the local `packages/hydrogen` package is only needed for templates that intentionally consume the repo-local + package. The React Router Oxygen template consumes the published `@shopify/hydrogen@preview` package instead. ## Hydrogen dependency -Use the workspace package in this repository: +Use the published `@shopify/hydrogen` `preview` dist-tag: ```json -"@shopify/hydrogen": "workspace:*" +"@shopify/hydrogen": "preview" ``` -This keeps the canonical template wired to the Hydrogen code under development, so repository builds and E2E tests -cover package and template changes together. +The published preview package now exposes the full React Router template surface, including `./customer-account`, +`./react`, and `./package.json`, so the template no longer needs a repo-local dependency, vendored tarball, or version +hack. The preview dist-tag resolves to a `0.0.0-preview-*` version, which satisfies `shopify hydrogen deploy`'s +`isHydrogenPreviewVersion` check (CLI #3819) and makes the CLI run `react-router build` for this template. -The source template is not the standalone distribution artifact. This repository's release flow replaces -`workspace:*` with the version selected by the `preview` dist-tag before generating the standalone lockfile. Preview -cuts use the `2026.10.0-preview.` format and must resolve from the registry with an integrity hash. - -Do not rely on `shopify hydrogen deploy` recognizing that version format. The template's deploy script passes -`--assets-dir dist/client --worker-dir dist/server`, which selects the template's `react-router build` output without -the CLI version sniff. The distributed package must still expose `./customer-account`, `./react`, and `./package.json`. +Do not replace the preview dist-tag with `workspace:*`, `file:../../packages/hydrogen`, a vendored +`file:./shopify-hydrogen-*.tgz`, or a classic Hydrogen semver range. ## React Router Template Pattern The concrete, file-by-file shape (package.json, vite.config.ts, react-router.config.ts, server.ts, entry.server/client, Worker lifecycle, Oxygen cache, env & types, shared-code migration) is in -**[reference/react-router-pattern.md](reference/react-router-pattern.md)** — read that file when implementing. These -instructions are specific to `templates/react-router`; do not generalize them to Next/Nuxt/Astro/Solid/SvelteKit +**[reference/react-router-pattern.md](reference/react-router-pattern.md)** — read that file when implementing. It +assumes `examples/react-router` -> `templates/react-router`; do not generalize it to Next/Nuxt/Astro/Solid/SvelteKit without adding framework-specific guidance first. ## Lockfile -The source template declares `"packageManager": "pnpm@10.33.0"` so local development uses the repository's package -manager and root lockfile. It does not commit a template lockfile; the root `.gitignore` keeps source-template -lockfiles out of this repository. +Keep a committed standalone npm `package-lock.json`. Oxygen's deploy workflow runs `npm ci`, which hard-fails without a +lockfile, and `@shopify/hydrogen: preview` is published so a real npm lockfile is achievable. + +Generate or refresh the lockfile whenever `templates/react-router/package.json` changes. Preferred options: + +- In a standalone copy of the template, run `npm install --ignore-scripts --no-audit --no-fund`, then copy the generated + `package-lock.json` back into `templates/react-router/package-lock.json`. +- If npm is blocked on a Shopify laptop, use the template's CI lockfile-generator workflow to run the same install and + commit the result, then copy that committed lockfile back. -During distribution, the release flow changes the template to `"packageManager": "npm@11.17.0"`, replaces -`workspace:*` with the version selected by the `preview` dist-tag, and generates `package-lock.json`. Oxygen requires -that generated lockfile for `npm ci`. Verify its -`node_modules/@shopify/hydrogen` entry resolves to a registry tarball with an integrity hash, not a `link:`, -`workspace:`, or vendored `file:` entry. Independently verify the template deploy script includes -`--assets-dir dist/client --worker-dir dist/server`; the lockfile does not prove the CLI will use those outputs. +Verify the lockfile's `node_modules/@shopify/hydrogen` entry resolves to a `0.0.0-preview-*` version with an integrity +hash and a public `https://registry.npmjs.org/` tarball URL (not `link:`, vendored `file:`, or an internal registry URL). +Keep the generator workflow in sync with dependency-file path filters. ### `minimumReleaseAge` supply-chain policy (org environments) @@ -169,7 +169,7 @@ template ships into users' repos, so it cannot rely on a relative path to the re The fastest way to deploy is the button above — it creates a new Oxygen project from this template and links it to your Shopify store. - When you deploy from the command line with `npm run deploy`, a linked storefront injects your env vars (`PUBLIC_STORE_DOMAIN`, `PRIVATE_STOREFRONT_API_TOKEN`, `SESSION_SECRET`) automatically, so the deployed site connects to your store with no extra config. + When you deploy from the command line with `npm run deploy`, a linked storefront injects your store env vars (`PUBLIC_STORE_DOMAIN`, `PRIVATE_STOREFRONT_API_TOKEN`) automatically, so the deployed site connects to your store with no extra config. ``` The button's `template=react-router` query param and the image URL's `preview` branch path are fixed — keep them @@ -183,19 +183,14 @@ in place, so the template does not need to ship a copy.) Before finishing: 1. Install with `CI=true` (see Prerequisites). -2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|hydrogen-classic|@react-router/node|@react-router/serve|lru-cache|catalog:|process\\.env|file:./shopify-hydrogen" templates/ -g '!pnpm-lock.yaml' -g '!package-lock.json' -g '!node_modules'`. Exclude `pnpm-lock.yaml`, `package-lock.json`, and `node_modules` — lockfiles can legitimately list transitive `@react-router/node`, `@react-router/serve`, and `lru-cache` even after the template drops them as direct deps; scanning them produces false positives. +2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|hydrogen-classic|@react-router/node|@react-router/serve|lru-cache|catalog:|process\\.env|workspace:|file:./shopify-hydrogen" templates/ -g '!pnpm-lock.yaml' -g '!package-lock.json' -g '!node_modules' -g '!.agents/**'`. Exclude `pnpm-lock.yaml`, `package-lock.json`, `node_modules`, and `.agents/**` — lockfiles and embedded docs can legitimately mention transitive packages or illustrative framework snippets after the template drops them as direct runtime deps. 3. Run the template typecheck (`react-router typegen && tsc --noEmit && hydrogen gql check --fail-on-warn`). -4. Run the template build. Confirm it creates `dist/client`, `dist/server`, and `dist/server/index.js`. -5. Run `node_modules/.bin/shopify hydrogen deploy --help` from the template directory. Confirm it lists - `--assets-dir` and `--worker-dir`. A deploy with fake credentials must get past flag parsing and fail on - authentication instead of reporting `Nonexistent flags`. -6. **Actually drive both runtimes, don't just check that a server starts** (static assets can serve even when the Worker isn't exercised): +4. Run the template build. +5. **Actually drive both runtimes, don't just check that a server starts** (static assets can serve even when the Worker isn't exercised): - `npm run dev`: request `/`, a product, a collection, `/search`, `/account`, `/cart` — expect HTTP 200 and live data. - `npm run preview` (= `react-router build && vite preview`, requires MiniOxygen `>= 4.2.0`): same requests through the built Worker. Confirm `.env` is loaded (root routes need real env, or they 500). -7. For distribution validation, copy the template to a temporary directory, replace `workspace:*` with the version - selected by the `preview` dist-tag, generate `package-lock.json`, and verify Hydrogen resolves to a registry tarball - with integrity. Leave the source template lockfile-free. -8. Report any validation not run and why (e.g. an org `minimumReleaseAge` policy blocked install — that is an environment gate, not a template defect). +6. Regenerate and commit the standalone npm `package-lock.json`; verify `@shopify/hydrogen` resolves to a `0.0.0-preview-*` registry tarball with integrity. +7. Report any validation not run and why (e.g. an org `minimumReleaseAge` policy blocked install — that is an environment gate, not a template defect). Expected local noise / environment gotchas (do not treat as template bugs): diff --git a/skills/create-oxygen-template/reference/react-router-pattern.md b/skills/create-oxygen-template/reference/react-router-pattern.md index 21eab270a5..d17faf3dde 100644 --- a/skills/create-oxygen-template/reference/react-router-pattern.md +++ b/skills/create-oxygen-template/reference/react-router-pattern.md @@ -1,7 +1,7 @@ # React Router Template Pattern -Concrete file-by-file shape for `templates/react-router`. Read this when implementing or maintaining the template. -Do not generalize these instructions to framework examples such as Next, Nuxt, Astro, Solid, or SvelteKit +Concrete file-by-file shape for `examples/react-router` -> `templates/react-router`. Read this when implementing the +template. Do not generalize these instructions to framework examples such as Next, Nuxt, Astro, Solid, or SvelteKit without adding framework-specific guidance first. For the high-level workflow, dependency mechanism, lockfile, and validation, see [SKILL.md](../SKILL.md). @@ -34,7 +34,7 @@ Use Vite/React Router scripts, not Hydrogen CLI dev/build scripts: "build": "react-router build", "preview": "react-router build && vite preview", "typecheck": "react-router typegen && tsc --noEmit && hydrogen gql check --fail-on-warn", - "deploy": "shopify hydrogen deploy --assets-dir dist/client --worker-dir dist/server" + "deploy": "shopify hydrogen deploy" } } ``` @@ -43,18 +43,14 @@ Remove Node-server scripts such as `start: react-router-serve ...` unless explic Dependencies: -- Keep app dependencies required by the template, such as `@shopify/hydrogen`, React, React Router, and `isbot`. +- Keep app dependencies required by the example, such as `@shopify/hydrogen`, React, React Router, and `isbot`. - Remove `lru-cache`, `@react-router/node`, and `@react-router/serve`. - Add `@shopify/mini-oxygen`, `@shopify/oxygen-workers-types`, and `@shopify/cli`. -- **`@shopify/hydrogen`: use `workspace:*` in this repository** so template builds and E2E exercise the package under - development. The `Shopify/hydrogen` release flow replaces it with the version selected by the `preview` dist-tag - before standalone lockfile generation. Preview cuts use `2026.10.0-preview.` and must resolve to a registry - tarball with an integrity hash. -- **`@shopify/cli`: pin `4.6.0` (minimum `4.4.0`)**. Those releases support the explicit deploy output flags. Keep - `--assets-dir dist/client --worker-dir dist/server` in the deploy script so the CLI runs `react-router build` and - uses this template's configured output without relying on a Hydrogen version sniff. -- **Package manager:** use `pnpm@10.33.0` in the source template so the monorepo has one package manager and lockfile. - The preview dist compiler changes the standalone template to `npm@11.17.0` before generating `package-lock.json`. +- **`@shopify/hydrogen`: use `preview`**. The published preview resolves to a `0.0.0-preview-*` registry package, + exposes the React Router template surface (including `./customer-account` and `./package.json`), and satisfies + `shopify hydrogen deploy`'s `isHydrogenPreviewVersion` check (CLI #3819) so deploy runs `react-router build`. No + repo-local dependency, vendored tarball, or version hack is needed. +- **`@shopify/cli`: pin `3.94.3`** unless a newer version has been verified with the deploy path. - **`@shopify/mini-oxygen`: pin `^4.2.0`** — its `oxygen()` plugin adds `configurePreviewServer`, which `vite preview` needs to run the Worker. - Add `"engines": {"node": "^22 || ^24"}`. @@ -106,12 +102,10 @@ Keep the `build.assetsInlineLimit: 0` and `ssr.optimizeDeps.include` interop set ## react-router.config.ts -Preserve the React Router config behavior required by the app: +Start from the copied React Router config and preserve behavior required by the app: ```ts export default { - appDirectory: "app", - buildDirectory: "dist", ssr: true, subResourceIntegrity: false, future: { @@ -121,7 +115,7 @@ export default { }; ``` -Preserve any additional future flags that the template needs. Keep `buildDirectory: "dist"` aligned with the deploy script's `dist/client` and `dist/server` flags. If one changes, update the other in the same change. +Preserve any additional future flags that the source example already needs. Do not force `buildDirectory: "dist"` unless the current MiniOxygen/deploy tooling or the user explicitly requires it. ## server.ts @@ -163,12 +157,12 @@ async function createAppLoadContext( } ``` -Use actual context names that match the template. Keep Shopify initialization and request handling in root middleware, -with `server.ts` responsible only for providing Worker values through React Router context and invoking the framework -request handler. The root middleware owns `handleShopifyRoutes` before `next()`, `handleShopifyRedirects` after a -framework 404, and storefront response headers on framework responses. Shopify handler responses already include -their own storefront response headers. The catch-all route should only produce the framework 404 that lets the root -middleware check for a Shopify redirect. +Use actual context names that match the template. If the app currently initializes Shopify context in root middleware, either: + +- keep that middleware and provide `env`, `waitUntil`, and `cache` through React Router context, or +- move only the top-level request setup into `server.ts` while preserving route behavior. + +Prefer the smaller app-code change. The React Router example already has most Shopify route handling in middleware; adapt it rather than rewriting route modules. ## entry.server.tsx and entry.client.tsx (REQUIRED) @@ -238,7 +232,7 @@ Oxygen is a Worker runtime. Do not create request-specific Shopify objects at mo Module scope is only appropriate for pure constants and stateless handler factories that do not capture request/env/session data. When in doubt, keep the object request-scoped until MiniOxygen runtime validation proves otherwise. -Keep request-time values in context instead of imported shared constants. Root middleware reads `env`, `cache`, and `waitUntil` from React Router context before creating `createShopifyRequestContext`, `createStorefrontClient`, and the storefront client (pass `cache` and `waitUntil` on its `config`). +Expect to adjust app code so request-time values come from context instead of imported shared constants. For example, root middleware may need to read `env`, `cache`, and `waitUntil` from React Router context before creating `createShopifyRequestContext`, `createStorefrontClient`, and the storefront client (pass `cache` on its `config`; an Oxygen template should also pass `waitUntil`). ## Oxygen cache @@ -253,15 +247,14 @@ Pass `cache` directly to `createStorefrontClient`'s `config` — the client wrap ## Env and types -Ship a `.env.example` (committed, blank) and a gitignored `.env`. Only the two real secrets are required; everything -else public lives in `app/lib/config.ts` (see config split). To smoke-test against the demo store in this repo, run -`pnpm run examples:secrets:decrypt` from the repository root (needs the ejson key locally). It writes the private token -and store domain to the gitignored `templates/react-router/.env`. +Ship a `.env.example` (committed, blank) and a gitignored `.env`. Keep the authoritative env list in +`templates/react-router/.env.example`; do not duplicate it in prose. The required real-store input is a private +Storefront API token, while Customer Accounts are optional and require their own account/session env vars. ```sh -SESSION_SECRET="replace-with-a-long-random-secret-32+" PRIVATE_STOREFRONT_API_TOKEN="" -# PUBLIC_STORE_DOMAIN="your-shop.myshopify.com" # optional override of app/lib/config.ts +# PUBLIC_STORE_DOMAIN="your-shop.myshopify.com" +# CUSTOMER_ACCOUNT_SESSION_SECRET="replace-with-a-long-random-secret-32+" ``` Add or update TypeScript declarations so the Worker env is typed: @@ -271,12 +264,10 @@ Add or update TypeScript declarations so the Worker env is typed: /// /// +import type { Env as AppEnv } from "./app/lib/platform"; + declare global { - interface Env { - SESSION_SECRET: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN?: string; - } + interface Env extends AppEnv {} } export {}; @@ -284,19 +275,19 @@ export {}; Add typed React Router contexts for Worker values the app needs, such as `env`, `cache`, and `waitUntil`. Use `createContext()`/`RouterContextProvider` consistently so middleware and loaders do not reach for globals or `process.env`. -Do not replace `tsconfig.json` wholesale. Keep `types` set to `["@shopify/oxygen-workers-types", "react-router", "vite/client"]` without `node`. Under `verbatimModuleSyntax`, any binding used only in a type position must use `import type`. Example: `defaultI18n` in `app/lib/storefront.ts` is used only as `typeof defaultI18n`, so it must be `import type {defaultI18n}`. +Start from the copied `tsconfig.json`; do not replace it wholesale. Include `@shopify/oxygen-workers-types`, `react-router`, and `vite/client` in `types`. Keep `node` only when build tooling in the same TypeScript program needs it. Under `verbatimModuleSyntax`, any binding used only in a type position must use `import type`. Keep `@types/node` in `devDependencies` (build tooling needs it at runtime); it is just not in the app `types` array. -Keep `hydrogen gql check --fail-on-warn` in `typecheck` and preserve the `@shopify/hydrogen/ts-plugin` entry. Hydrogen packages both schemas and their gql.tada tooling. +Keep `hydrogen gql check --fail-on-warn` in `typecheck` and preserve the `@shopify/hydrogen/ts-plugin` entry from the source example. Hydrogen packages both schemas and their gql.tada tooling. ## Shared code migration Replace each `@shared/*` import with template-local code: - config constants -> local `app/lib/config.ts` (see config split below) -- private token lookup -> local `app/lib/env.ts` -- buyer IP helper -> local `app/lib/buyer-ip.ts` (replace `process.env.NODE_ENV` with `import.meta.env.PROD`) +- Worker env/context helpers -> local `app/lib/platform.ts` +- private token lookup and buyer IP helper -> local `app/lib/config.ts` - encrypted customer session -> copy into `app/lib/customer-session.ts` if Customer Account remains enabled (it is already Web-Crypto based and Oxygen-safe) - storefront cache adapter -> remove if replacing LRU with Oxygen `caches.open` @@ -304,15 +295,12 @@ Replace each `@shared/*` import with template-local code: Additionally, keep `lib/route-templates.ts` unchanged — `routeTemplates` is required by `handleShopifyRedirects({routeTemplates})`, ``, and `getPredictiveSearchItemUrl(product, {routes: routeTemplates, …})`. -**Config split (public vs secret).** Do not try to make everything env-driven — `ShopifyScripts` (in the root -`Layout`) and analytics run on the CLIENT, where the Worker `env` is not available. Split it: +**Config split (public vs secret).** Keep Worker env reads behind `app/lib/platform.ts` and route/middleware boundaries. Split it: -- Public identity -> bundled `app/lib/config.ts` (store domain, public Storefront token, shop/storefront IDs, - Customer Account client ID, `defaultI18n`, `analyticsShop`, `analyticsConsent`). These are non-secret and safe in the - client bundle; default them to the demo store so the template runs out of the box. -- Real secrets -> Worker `env`, read on the server only: `SESSION_SECRET`, `PRIVATE_STOREFRONT_API_TOKEN`, plus an - optional `PUBLIC_STORE_DOMAIN` override. Read them in root middleware, not at module scope. +- Public defaults -> bundled `app/lib/config.ts` (`defaultI18n`, analytics consent, fallback shop identity). These are non-secret and keep the template running out of the box. +- Runtime bindings -> Worker `env`, read on the server only and passed through root loader data when browser code needs public values such as Shopify Scripts shop identity. +- Real secrets -> Worker `env`, read in root middleware, not at module scope. -This avoids a fragile loader->client refactor and keeps every feature working. Note this applies beyond root middleware: route modules also import public identity (e.g. `analyticsShop`) on the client, so keeping it as a bundled `config.ts` constant — rather than something read from `env` — is what makes those client imports work. +This keeps private values server-only while still letting browser code receive safe public values through loader data. Keep Customer Account, cart, search, analytics, and other example features unless the user explicitly asks to remove them. diff --git a/templates/react-router/.env.example b/templates/react-router/.env.example index 02393da191..9f2a5135fb 100644 --- a/templates/react-router/.env.example +++ b/templates/react-router/.env.example @@ -1,15 +1,22 @@ -# Worker environment. Copy to `.env` and fill in. The Hydrogen CLI loads `.env` -# into the Oxygen worker environment for `pnpm dev` / `pnpm preview`; on Oxygen a -# linked storefront injects these automatically. -# -# Mode is auto-detected: with a PRIVATE_STOREFRONT_API_TOKEN present the app talks -# to the real store; with none it falls back to the tokenless mock.shop demo (so a -# fresh deploy always renders). Set MOCK_SHOP=1 to force the mock explicitly. - -# Force the tokenless mock.shop demo (also the default when no token is set). +# Worker environment. Copy to `.env` and fill in values for a real store. +# With no private Storefront API token, the template runs against mock.shop. + +# Force the tokenless mock.shop demo. # MOCK_SHOP=1 -# Real store (server-only). Set both for real-store mode. On Oxygen, a linked -# storefront injects these for you. +# Real store. Oxygen injects PUBLIC_STORE_DOMAIN and PRIVATE_STOREFRONT_API_TOKEN +# for a linked storefront; set them locally when not using mock.shop. PUBLIC_STORE_DOMAIN= PRIVATE_STOREFRONT_API_TOKEN= + +# SEO canonical URL for sitemap, robots, and meta tags. +PUBLIC_SITE_ORIGIN= + +# Shopify Scripts identity. Replace these when pointing at your own store. +SHOP_ID= +PUBLIC_STOREFRONT_ID= + +# Optional Customer Accounts. The account route stays disabled until all three +# values are set and the app is running against a real store. +PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID= +CUSTOMER_ACCOUNT_SESSION_SECRET= diff --git a/templates/react-router/.gitignore b/templates/react-router/.gitignore index 09d8595afc..2505964730 100644 --- a/templates/react-router/.gitignore +++ b/templates/react-router/.gitignore @@ -1,5 +1,11 @@ node_modules/ -dist/ +.env build/ +dist/ .react-router/ -.env +.turbo/ +tsconfig.tsbuildinfo +*.log +.DS_Store +storefront-graphql-env.d.ts +customer-account-graphql-env.d.ts diff --git a/templates/react-router/LICENSE b/templates/react-router/LICENSE index bf165a7b35..887c50f123 100644 --- a/templates/react-router/LICENSE +++ b/templates/react-router/LICENSE @@ -1,9 +1,21 @@ MIT License -Copyright (c) 2023-present, Shopify Inc. +Copyright (c) Shopify -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/templates/react-router/README.md b/templates/react-router/README.md index bba05bf888..0a939f647b 100644 --- a/templates/react-router/README.md +++ b/templates/react-router/README.md @@ -1,80 +1,98 @@ -# Hydrogen React Router template +# React Router Hydrogen template Deploy to Oxygen -A React Router 7 (framework mode, SSR) storefront built on -[`@shopify/hydrogen`](https://www.npmjs.com/package/@shopify/hydrogen) and the -Oxygen runtime through Vite and Mini Oxygen. It's a starting point you can clone -and build your store on top of — five pages on a shared layout, with a real cart, -analytics, and a consent banner wired up. +A standalone React Router 7 storefront built on Hydrogen 3 and optimized for Oxygen deployments. Copy this folder into a new project, install dependencies, and run it with no Shopify secrets against mock.shop. -## Pages +## What's Included -- `/` — home (editorial hero, best sellers, shop by category) -- `/products/:handle` — product detail (gallery, variants, add to cart) -- `/collections` — all collections -- `/collections/:handle` — collection with filters, sort, and pagination -- `/search` — product search with the same filtering -- `/cart` — cart (also the no-JS fallback for the cart drawer) +- `/` — home with featured products and collections. +- `/collections` and `/collections/:handle` — collections, filters, sort, and pagination. +- `/products/:handle` — product details, variant selection, add to cart, Shop Pay, and related products. +- `/search` — search with predictive search, filters, sort, and pagination. +- `/cart` — server-rendered cart page and no-JS fallback for the cart drawer. +- `/account` — optional Customer Accounts profile/login surface for real stores. +- `/sitemap.xml` and `/robots.txt` — SEO resource routes. -## What it demonstrates - -- Server `loader`s as the data path; each route owns its GraphQL query (typed via - `gql.tada`). -- A real cart: storefront client + request handlers + `/api/cart` + an accessible - cart drawer wired to Shopify Standard Actions. -- A shared layout (header with mobile nav, footer, announcement bar). -- Analytics + a consent banner. -- The design tokens in `app/tokens.css` and SVG icons in `public/icons/`. - -## Run it +## Run Locally ```bash npm install +npm run dev ``` -**Zero-config demo** — runs against `mock.shop` (a public mock Storefront API, no -account or token needed): +With no `PRIVATE_STOREFRONT_API_TOKEN`, the template uses mock.shop and runs without configuration. + +## Use A Real Store ```bash cp .env.example .env -# uncomment MOCK_SHOP=1 in .env -npm run dev ``` -**Against a real store** — set your store domain and a **private** Storefront API -token, then run normally: +Set these values in `.env`: + +```bash +PUBLIC_STORE_DOMAIN=.myshopify.com +PRIVATE_STOREFRONT_API_TOKEN= +``` + +Oxygen injects those bindings automatically for linked storefronts. `MOCK_SHOP=1` forces the mock.shop demo. + +## Deploy to Oxygen + +Deploy to Oxygen + +The fastest way to deploy is the button above. It creates a new Oxygen project from this template and links it to your Shopify store. + +For manual deploys, run: + +```bash +npm run deploy +``` + +A linked Oxygen storefront injects `PUBLIC_STORE_DOMAIN` and `PRIVATE_STOREFRONT_API_TOKEN` automatically. + +## Customer Accounts + +Customer Accounts are optional and disabled until the template is using a real store and all account values are present: ```bash -cp .env.example .env # set PUBLIC_STORE_DOMAIN + PRIVATE_STOREFRONT_API_TOKEN -npm run dev # Vite/Mini Oxygen loads .env into the worker environment +PUBLIC_STORE_DOMAIN=.myshopify.com +PRIVATE_STOREFRONT_API_TOKEN= +SHOP_ID= +PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID= +CUSTOMER_ACCOUNT_SESSION_SECRET=<32-plus-character-secret> ``` -Mode is **auto-detected**: when a `PRIVATE_STOREFRONT_API_TOKEN` is present the -app talks to the real store (`PUBLIC_STORE_DOMAIN`, falling back to the default in -`app/lib/shop.ts`); with none it falls back to the `mock.shop` demo, so a fresh -deploy always renders. **On Oxygen, a linked storefront injects these env vars -automatically** — the deployed site connects to your store with no extra config -(and shows the `mock.shop` demo until it's linked). `MOCK_SHOP=1` forces mock. -(`mock.shop` and the Hydrogen Preview store are different data sources.) +Local Customer Account OAuth requires an HTTPS origin configured in Shopify admin. This template's default `npm run dev` server is HTTP, so use an Oxygen deployment for account testing or run the app behind your own trusted HTTPS tunnel/origin. ## Scripts | Script | Does | | --- | --- | | `npm run dev` | Start the Vite dev server with Mini Oxygen. | -| `npm run build` | Production React Router build for Oxygen. | -| `npm run preview` | Build and preview locally with Vite and Mini Oxygen. | -| `npm run deploy` | Deploy to Oxygen with the Shopify CLI. | -| `npm run typecheck` | React Router typegen + TypeScript + Hydrogen GraphQL checks. | - -## Where to start - -- Swap the store in `app/lib/shop.ts` + `.env`. -- Routes live in `app/routes/`; shared UI in `app/components/`; data/query helpers - in `app/lib/`. -- The design is yours to change — `app/tokens.css` holds the design tokens; the - components use them via semantic classes. +| `npm run build` | Build the React Router app for Oxygen. | +| `npm run preview` | Build and preview locally with Mini Oxygen. | +| `npm run deploy` | Deploy to Oxygen with Shopify CLI. | +| `npm run typecheck` | Generate React Router types, run TypeScript, and validate GraphQL. | + +## Environment + +- `MOCK_SHOP` — set to `1` to force mock.shop. +- `PUBLIC_STORE_DOMAIN` — real store domain. +- `PRIVATE_STOREFRONT_API_TOKEN` — private Storefront API token. +- `PUBLIC_SITE_ORIGIN` — canonical origin for sitemap, robots, and meta tags. +- `SHOP_ID` — numeric Shopify shop ID for Customer Accounts. +- `PUBLIC_STOREFRONT_ID` — storefront ID for Shopify scripts. +- `PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID` — Customer Account API client ID. +- `CUSTOMER_ACCOUNT_SESSION_SECRET` — private cookie encryption secret. + +## Where To Start + +- Routes live in `app/routes`. +- Shared UI lives in `app/components`. +- Storefront, cart, account, and runtime helpers live in `app/lib`. +- Styling lives in `app/app.css` and uses Tailwind CSS 4. ## License diff --git a/templates/react-router/app/app.css b/templates/react-router/app/app.css index ab3b3e5b09..844e680b6a 100644 --- a/templates/react-router/app/app.css +++ b/templates/react-router/app/app.css @@ -1,87 +1,1003 @@ @import "tailwindcss"; -@import "./tokens.css"; - -/* App-only CSS the frozen core (tokens.css) does not own. - * - * The core owns the design tokens AND the drawer *box* (.drawer-right / - * .drawer-left: position, size, surface, backdrop color, responsive width). - * It does NOT own the open/close MOTION or the body scroll lock — those are - * app-owned and live here. - * - * Do NOT add web fonts or @theme font overrides here: the core tokens lock the - * example typography to system-ui. - */ - -/* Drawer slide-in animation. - * The cart drawer uses `.drawer-right` (slides from the inline-end edge); the - * mobile-nav drawer uses `.drawer-left` (slides from the inline-start edge). - * `overlay`/`display` with `allow-discrete` keep the dialog transitionable - * while it leaves the top layer on close. */ -.drawer-right, -.drawer-left { - transition: - transform 250ms cubic-bezier(0.22, 1, 0.36, 1), - overlay 250ms allow-discrete, - display 250ms allow-discrete; + +/* + Storefront Kit examples core tokens. + Transcribed from examples/core/tokens.css — the single source of truth for + styling. The full token set, semantic component classes, and utilities are + inlined here so the React Router example's components can rely on the same + cross-example class anchors (button-primary, badge-sale, type-display, + overlay-dark, drawer-right, dialog-center, consent-banner, etc.). +*/ + +@theme { + /* Surfaces */ + --color-surface: #ffffff; + --color-surface-secondary: #e3e3e3; + --color-on-surface: #000000; + --color-on-surface-secondary: #4b5563; + --color-border: #e5e7eb; + + /* ─── Brand: the single source of truth ──────────────────────────────── */ + --color-primary: #1b4332; + + /* Interactive — derived from --color-primary. */ + --color-interactive: var(--color-primary); + --color-interactive-hover: color-mix(in oklab, var(--color-primary) 85%, black); + --color-interactive-active: color-mix(in oklab, var(--color-primary) 72%, black); + --color-interactive-text: #ffffff; + + /* Link text — derived from --color-interactive. Link text has a 4.5:1 + contrast threshold (text), whereas --color-accent is the decorative + focus-ring/badge tint (3:1). Routing link TEXT to --color-link keeps + accent rings/badges from being over-darkened to satisfy text contrast. */ + --color-link: var(--color-interactive); + --color-link-hover: var(--color-interactive-hover); + + /* Accent — a lighter, desaturated tint of the brand. */ + --color-accent: oklch(from var(--color-primary) calc(l + 0.17) calc(c * 0.39) calc(h - 9)); + + /* Button variants */ + --color-button-primary: var(--color-interactive); + --color-button-primary-hover: var(--color-interactive-hover); + --color-button-primary-active: var(--color-interactive-active); + --color-button-primary-text: var(--color-interactive-text); + --color-button-secondary: var(--color-surface-secondary); + --color-button-secondary-text: var(--color-on-surface); + --color-button-outline-border: var(--color-interactive); + --color-button-outline-text: var(--color-interactive); + /* Shop Pay brand button */ + --color-button-shop-pay: #5a31f4; + --color-button-shop-pay-text: #ffffff; + + /* Commerce */ + --color-sale: #dc2626; + --color-compare: var(--color-on-surface-secondary); + + /* Semantic states */ + --color-success: #047857; + --color-warning: #b45309; + --color-critical: var(--color-sale); + --color-info: #2563eb; + + /* Overlay */ + --color-overlay: rgb(0 0 0 / 0.95); + --color-overlay-strong: rgb(0 0 0 / 0.7); + --color-overlay-subtle: rgb(0 0 0 / 0.5); + --color-overlay-scrim: rgb(0 0 0 / 0.25); + + /* Hover */ + --color-hover: rgb(0 0 0 / 0.03); + + /* Radius */ + --radius-sm: 0.25rem; + --radius: 0.5rem; + --radius-lg: 0.75rem; + --radius-button: 0.5rem; + --radius-card: 0; + --radius-input: 0.5rem; + --radius-badge: 9999px; + + /* Typography */ + --font-body: system-ui, -apple-system, sans-serif; + --font-heading: system-ui, -apple-system, sans-serif; + + /* Easing */ + --ease-ui: cubic-bezier(0.16, 1, 0.3, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); + + /* Layout spacing */ + --spacing-page: 80rem; + --spacing-margin: 1rem; + --spacing-control-height: 2.125rem; + --spacing-touch-target: 2.75rem; + --spacing-filter-indicator: 1.25rem; + --spacing-filter-swatch: 1.75rem; + --spacing-dialog-max-height: 85dvh; + --spacing-search-modal-width: min(45rem, 90vw); + --spacing-search-modal-offset-block-start: 15dvh; + --spacing-hero: 60dvh; + --spacing-drawer-width: 480px; + --spacing-header-nav-inline: 6px; + --spacing-nav-dropdown-offset-inline: 13px; + --spacing-nav-dropdown-offset-block: 10px; + --spacing-nav-dropdown-enter-offset: 4px; + --spacing-nav-dropdown-surface-padding: 16px; + --spacing-nav-dropdown-column-width: 120px; + --spacing-nav-dropdown-gap-inline: 24px; + --spacing-nav-dropdown-item-padding-block: 3px; + --spacing-cart-count-badge: 22px; + --spacing-cart-line-thumbnail-width: 6rem; + + /* Shadows */ + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + /* Aspect ratios */ + --aspect-portrait: 4/5; + --aspect-landscape: 16/9; } -.drawer-right { - transform: translateX(100%); +:root { + --type-paragraph-lg-font: var(--font-body); + --type-paragraph-lg-size: 18px; + --type-paragraph-font: var(--font-body); + --type-paragraph-size: 16px; + --type-paragraph-sm-font: var(--font-body); + --type-paragraph-sm-size: 14px; + --type-heading-2xl-font: var(--font-heading); + --type-heading-2xl-size: 36px; + --type-heading-xl-font: var(--font-heading); + --type-heading-xl-size: 30px; + --type-heading-lg-font: var(--font-heading); + --type-heading-lg-size: 24px; + --type-heading-md-font: var(--font-heading); + --type-heading-md-size: 20px; + --type-heading-sm-font: var(--font-heading); + --type-heading-sm-size: 18px; + --type-subheading-font: var(--font-body); + --type-subheading-size: 12px; + --icon-stroke-width: 1.5; } -.drawer-left { - transform: translateX(-100%); +@layer base { + h1 { + line-height: 1.2; + } + + input[type="text"], + input[type="email"], + input[type="password"], + input[type="search"], + input[type="tel"], + input[type="url"], + input[type="number"], + input[type="date"], + textarea, + select { + width: 100%; + color: var(--color-on-surface); + background: var(--color-surface); + border-radius: var(--radius-input); + padding: var(--input-padding-y, 0.375rem) var(--input-padding-x, 0.75rem); + border: 1px solid var(--color-input-border, var(--color-border)); + font-family: var(--font-body); + font-size: var(--input-font-size, 0.875rem); + line-height: 1.5; + appearance: none; + } + + input::placeholder, + textarea::placeholder { + color: var(--color-on-surface-secondary); + } + + input:focus-visible, + textarea:focus-visible, + select:focus-visible { + outline: 2px solid var(--color-interactive); + outline-offset: 2px; + } + + /* Visible keyboard focus for plain links (additive over inline utilities). */ + a:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); + } + + /* Pressed state for plain links; .button-* excluded so button :active wins. */ + a:not(.button-primary):not(.button-secondary):not(.button-outline):not(.button-icon):not( + .button-ghost + ):not(.button-surface):not(.button-shop-pay):active { + opacity: 0.7; + } + + input:disabled, + textarea:disabled, + select:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--color-surface-secondary); + } + + :where( + .button-primary, + .button-secondary, + .button-outline, + .button-ghost, + .button-surface, + .button-shop-pay + ) { + padding: var(--button-padding-y, 0.375rem) var(--button-padding-x, 0.75rem); + font-size: var(--button-font-size, 0.875rem); + } + + :where( + .badge-default, + .badge-sale, + .badge-accent, + .badge-info, + .badge-success, + .badge-warning, + .badge-soldout + ) { + padding: var(--badge-padding-y, 0.125rem) var(--badge-padding-x, 0.5rem); + font-size: var(--badge-font-size, 0.75rem); + } + + @media (min-width: 48rem) { + :root { + --spacing-margin: 2.5rem; + } + } } -.drawer-right[open], -.drawer-left[open] { - transform: translateX(0); +@layer components { + .logo-size-custom { + width: auto; + } + + .overlay-dark { + background: linear-gradient( + to top, + rgb(0 0 0 / 0.8) 0%, + rgb(0 0 0 / 0.4) 60%, + transparent 100% + ); + } + .overlay-medium { + background: linear-gradient( + to top, + rgb(0 0 0 / 0.55) 0%, + rgb(0 0 0 / 0.2) 60%, + transparent 100% + ); + } + .overlay-light { + background: linear-gradient(to top, rgb(0 0 0 / 0.3) 0%, transparent 70%); + } + .overlay-white { + background: linear-gradient( + to top, + rgb(255 255 255 / 0.9) 0%, + rgb(255 255 255 / 0.4) 60%, + transparent 100% + ); + } + + .swatch-sm { + width: 1.5rem; + height: 1.5rem; + } + .swatch-md { + width: 2rem; + height: 2rem; + } + .swatch-lg { + width: 2.5rem; + height: 2.5rem; + } + .swatch-scrim { + background: var(--color-overlay-scrim); + } + + .filter-checkbox, + .filter-swatch { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-on-surface); + cursor: pointer; + } + .filter-checkbox { + block-size: var(--spacing-filter-indicator); + inline-size: var(--spacing-filter-indicator); + border-radius: var(--radius-sm); + } + .filter-swatch { + block-size: var(--spacing-filter-swatch); + inline-size: var(--spacing-filter-swatch); + border-radius: var(--radius-badge); + background: var(--filter-swatch-color, var(--swatch-color, var(--color-surface))); + } + .filter-checkbox[aria-checked="true"], + .filter-checkbox[aria-pressed="true"], + .filter-checkbox.is-selected, + input:checked + .filter-checkbox, + label:has(input:checked) .filter-checkbox { + background: var(--color-interactive); + color: var(--color-interactive-text); + border-color: var(--color-interactive); + } + .filter-swatch[aria-checked="true"], + .filter-swatch[aria-pressed="true"], + .filter-swatch.is-selected, + input:checked + .filter-swatch, + label:has(input:checked) .filter-swatch { + background: + linear-gradient(var(--color-overlay-scrim), var(--color-overlay-scrim)), + var(--filter-swatch-color, var(--swatch-color, var(--color-surface))); + color: var(--color-interactive-text); + border-color: var(--color-interactive); + } + .filter-checkbox:focus-visible, + .filter-swatch:focus-visible, + input:focus-visible + .filter-checkbox, + input:focus-visible + .filter-swatch, + label:has(input:focus-visible) .filter-checkbox, + label:has(input:focus-visible) .filter-swatch { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + .filter-checkbox[aria-disabled="true"], + .filter-checkbox:disabled, + .filter-swatch[aria-disabled="true"], + .filter-swatch:disabled, + label:has(input:disabled) .filter-checkbox, + label:has(input:disabled) .filter-swatch { + opacity: 0.5; + cursor: not-allowed; + } + .filter-check-icon { + block-size: 0.875rem; + inline-size: 0.875rem; + color: var(--color-interactive-text); + stroke: currentColor; + fill: none; + opacity: 0; + pointer-events: none; + } + .filter-checkbox[aria-checked="true"] .filter-check-icon, + .filter-checkbox[aria-pressed="true"] .filter-check-icon, + .filter-checkbox.is-selected .filter-check-icon, + .filter-swatch[aria-checked="true"] .filter-check-icon, + .filter-swatch[aria-pressed="true"] .filter-check-icon, + .filter-swatch.is-selected .filter-check-icon, + input:checked + :is(.filter-checkbox, .filter-swatch) .filter-check-icon, + label:has(input:checked) .filter-check-icon { + opacity: 1; + } + + .option-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-block-size: var(--spacing-touch-target); + min-inline-size: var(--spacing-touch-target); + padding-inline: 0.75rem; + padding-block: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-on-surface); + cursor: pointer; + } + .option-pill[aria-pressed="true"], + .option-pill[aria-current="true"], + .option-pill.is-selected { + background: var(--color-interactive); + color: var(--color-interactive-text); + border-color: var(--color-interactive); + } + .option-pill[data-available="false"] { + opacity: 0.5; + text-decoration: line-through; + } + .option-pill:disabled, + .option-pill[aria-disabled="true"], + .option-pill.is-disabled { + opacity: 0.5; + cursor: not-allowed; + } + .option-pill:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + /* Pressed feedback — matches the branded-button press ramp: an unselected + pill darkens to the secondary surface; a selected (filled) pill darkens to + the interactive-active shade. Guarded so disabled pills don't react. */ + .option-pill:not(:disabled):not([aria-disabled="true"]):active { + background: var(--color-surface-secondary); + } + .option-pill[aria-pressed="true"]:not(:disabled):not([aria-disabled="true"]):active, + .option-pill[aria-current="true"]:not(:disabled):not([aria-disabled="true"]):active, + .option-pill.is-selected:not(:disabled):not([aria-disabled="true"]):active { + background: var(--color-interactive-active); + } + @media (hover: hover) { + .option-pill:not([aria-pressed="true"]):not([aria-current="true"]):not(.is-selected):not( + :disabled + ):hover { + background: var(--color-surface-secondary); + } + } + + .chip-filled { + background: var(--chip-bg, var(--color-surface-secondary)); + color: var(--color-on-surface); + } + .chip-outlined { + background: var(--chip-bg, transparent); + color: var(--color-on-surface); + border: 1px solid var(--color-border); + } + @media (hover: hover) { + .chip-filled:hover, + .chip-outlined:hover { + background: color-mix( + in oklab, + var(--chip-bg, var(--color-surface-secondary)) 90%, + var(--color-on-surface) + ); + } + } + + .badge-default { + background: var(--color-badge-default, var(--color-surface-secondary)); + color: var(--color-badge-default-text, var(--color-on-surface)); + } + .badge-sale { + background: var(--color-badge-sale, var(--color-sale)); + color: var(--color-badge-sale-text, var(--color-interactive-text)); + } + .badge-accent { + background: var(--color-badge-accent, var(--color-accent)); + color: var(--color-badge-accent-text, var(--color-interactive-text)); + } + .badge-info { + background: var(--color-badge-info, var(--color-info)); + color: var(--color-badge-info-text, var(--color-interactive-text)); + } + .badge-success { + background: var(--color-badge-success, var(--color-success)); + color: var(--color-badge-success-text, var(--color-interactive-text)); + } + .badge-warning { + background: var(--color-badge-warning, var(--color-warning)); + color: var(--color-badge-warning-text, var(--color-interactive-text)); + } + .badge-soldout { + background: var(--color-badge-soldout, var(--color-on-surface)); + color: var(--color-badge-soldout-text, var(--color-surface)); + } + + .button-primary { + background: var(--color-button-primary); + color: var(--color-button-primary-text); + } + .button-secondary { + background: var(--color-button-secondary); + color: var(--color-button-secondary-text); + border: 1px solid var(--color-border); + } + .button-outline { + background: transparent; + color: var(--color-button-outline-text); + border: 2px solid var(--color-button-outline-border); + } + .button-shop-pay { + background: var(--color-button-shop-pay); + color: var(--color-button-shop-pay-text); + } + .button-ghost, + .button-icon { + background: transparent; + color: var(--color-on-surface); + } + .button-icon { + min-block-size: var(--spacing-touch-target); + min-inline-size: var(--spacing-touch-target); + display: inline-flex; + align-items: center; + justify-content: center; + } + .button-surface { + background: var(--color-surface); + color: var(--color-on-surface); + } + :where(.button-primary, .button-secondary, .button-outline, .button-icon):is( + :disabled, + [aria-disabled="true"] + ) { + opacity: 0.5; + } + @media (hover: hover) { + .button-primary:not(:disabled):not([aria-disabled="true"]):hover { + background: var(--color-button-primary-hover); + } + .button-secondary:not(:disabled):not([aria-disabled="true"]):hover { + background: color-mix(in oklab, var(--color-button-secondary) 90%, black); + } + .button-shop-pay:hover { + background: color-mix(in oklab, var(--color-button-shop-pay) 88%, black); + } + .button-outline:not(:disabled):not([aria-disabled="true"]):hover, + .button-ghost:hover { + background: var(--color-surface-secondary); + } + .button-surface:hover { + background: color-mix(in oklab, var(--color-surface) 90%, transparent); + } + .button-icon:not(:disabled):not([aria-disabled="true"]):hover { + opacity: 0.7; + } + } + + .button-primary:not(:disabled):not([aria-disabled="true"]):active { + background: var(--color-button-primary-active); + } + .button-secondary:not(:disabled):not([aria-disabled="true"]):active { + background: color-mix(in oklab, var(--color-button-secondary) 82%, black); + } + .button-shop-pay:active { + background: color-mix(in oklab, var(--color-button-shop-pay) 78%, black); + } + .button-outline:not(:disabled):not([aria-disabled="true"]):active, + .button-ghost:not(:disabled):not([aria-disabled="true"]):active { + background: color-mix(in oklab, var(--color-surface-secondary) 88%, black); + } + .button-icon:not(:disabled):not([aria-disabled="true"]):active { + opacity: 0.55; + } + .button-surface:not(:disabled):not([aria-disabled="true"]):active { + background: color-mix(in oklab, var(--color-surface) 90%, transparent); + } + + /* Visible keyboard focus for every .button-* class (additive over inline + utilities). */ + :where( + .button-primary, + .button-secondary, + .button-outline, + .button-icon, + .button-ghost, + .button-surface, + .button-shop-pay + ):focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + + .quantity-selector-outlined { + border: none; + background: transparent; + } + .quantity-selector-outlined:focus-within { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + .quantity-selector-filled { + background: var(--color-surface-secondary); + border: 1px solid transparent; + } + + .type-display { + font-size: var(--type-heading-2xl-size); + font-family: var(--type-heading-2xl-font); + font-weight: 300; + line-height: 1.25; + } + .type-heading-xl { + font-size: var(--type-heading-xl-size); + font-family: var(--type-heading-xl-font); + font-weight: 300; + } + .type-heading-lg { + font-size: var(--type-heading-lg-size); + font-family: var(--type-heading-lg-font); + font-weight: 300; + } + .type-heading-md { + font-size: var(--type-heading-md-size); + font-family: var(--type-heading-md-font); + font-weight: 400; + } + .type-heading-sm { + font-size: var(--type-heading-sm-size); + font-family: var(--type-heading-sm-font); + font-weight: 400; + } + .type-body-lg { + font-size: var(--type-paragraph-lg-size); + font-family: var(--type-paragraph-lg-font); + } + .type-body { + font-size: var(--type-paragraph-size); + font-family: var(--type-paragraph-font); + line-height: 1.625; + } + .type-body-sm { + font-size: var(--type-paragraph-sm-size); + font-family: var(--type-paragraph-sm-font); + } + .type-overline { + font-size: var(--type-subheading-size); + font-family: var(--type-subheading-font); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .card { + position: relative; + } + .card-link { + text-decoration: none; + } + .card-link::after { + content: ""; + position: absolute; + inset: 0; + } + .card-link:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + + .cart-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + block-size: var(--spacing-cart-count-badge); + inline-size: var(--spacing-cart-count-badge); + border-radius: var(--radius-badge); + background: var(--color-surface-secondary); + color: var(--color-on-surface); + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + } + + [data-header-nav-group] { + padding-inline: var(--spacing-header-nav-inline); + } + @media (min-width: 48rem) { + [data-header-nav-group] { + padding-inline: var(--spacing-margin); + } + } + + .nav-has-dropdown { + position: relative; + } + .nav-dropdown { + position: absolute; + top: 100%; + inset-inline-start: calc(-1 * var(--spacing-nav-dropdown-offset-inline)); + padding-block-start: var(--spacing-nav-dropdown-offset-block); + opacity: 0; + visibility: hidden; + transform: translateY(calc(-1 * var(--spacing-nav-dropdown-enter-offset))); + transition: + opacity 0.15s var(--ease-ui), + transform 0.15s var(--ease-ui), + visibility 0.15s; + z-index: 50; + pointer-events: none; + } + @media (hover: hover) { + .nav-has-dropdown:hover .nav-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; + } + } + .nav-has-dropdown:focus-within .nav-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; + } + .nav-dropdown-inner { + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: var(--spacing-nav-dropdown-surface-padding); + display: grid; + grid-template-columns: var(--spacing-nav-dropdown-column-width); + grid-auto-flow: column; + gap: 0 var(--spacing-nav-dropdown-gap-inline); + min-width: var(--spacing-nav-dropdown-column-width); + } + .nav-dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.75; + color: var(--color-on-surface); + text-decoration: none; + padding: var(--spacing-nav-dropdown-item-padding-block) 0; + white-space: nowrap; + transition: opacity 0.15s var(--ease-ui); + } + .nav-dropdown-inner:hover > .nav-dropdown-item { + opacity: 0.6; + } + .nav-dropdown-inner > .nav-dropdown-item:hover { + opacity: 1; + } + .nav-dropdown-item:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); + } + + /* Privacy / cookie consent banner. */ + .consent-banner { + position: fixed; + inset-inline: 0; + inset-block-end: 0; + z-index: 60; + display: flex; + flex-direction: column; + gap: 1rem; + width: min(calc(100% - 2 * var(--spacing-margin)), var(--spacing-page)); + margin-inline: auto; + margin-block-end: var(--spacing-margin); + padding: 1.25rem; + background: var(--color-surface); + color: var(--color-on-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + } + .consent-banner-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + @media (min-width: 48rem) { + .consent-banner { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + } + .consent-banner-actions { + flex-direction: row; + flex-shrink: 0; + align-items: center; + } + } } -@starting-style { - .drawer-right[open] { - transform: translateX(100%); +@layer utilities { + .number-reset { + appearance: textfield; + } + .number-reset::-webkit-outer-spin-button, + .number-reset::-webkit-inner-spin-button { + appearance: none; + } + + input[type="search"]::-webkit-search-cancel-button, + input[type="search"]::-webkit-search-decoration { + appearance: none; + -webkit-appearance: none; + display: none; + } + + .marker-hidden { + list-style: none; + } + .marker-hidden::-webkit-details-marker { + display: none; + } + + .scrollbar-none { + scrollbar-width: none; + } + .scrollbar-none::-webkit-scrollbar { + display: none; + } + + @media (min-width: 48rem) { + .product-grid { + grid-template-columns: 2fr 1fr; + } + } + + .content-visibility-auto { + content-visibility: auto; + contain-intrinsic-block-size: auto var(--section-height, 500px); } - .drawer-left[open] { - transform: translateX(-100%); + .contain-paint { + contain: paint; + } + + .button-primary:is(:disabled, [aria-disabled="true"]), + .button-secondary:is(:disabled, [aria-disabled="true"]), + .button-outline:is(:disabled, [aria-disabled="true"]), + .button-icon:is(:disabled, [aria-disabled="true"]) { + cursor: not-allowed; + } + + .richtext { + color: var(--color-on-surface); + line-height: 1.625; + } + .richtext h1, + .richtext h2, + .richtext h3, + .richtext h4, + .richtext h5, + .richtext h6 { + font-family: var(--font-heading); + font-weight: 700; + color: var(--color-on-surface); + line-height: 1.25; + margin-block-start: 1.5rem; + margin-block-end: 0.75rem; + } + .richtext p { + margin-block-end: 1rem; + } + .richtext ul { + list-style: disc; + padding-inline-start: 1.5rem; + margin-block-end: 1rem; + } + .richtext ol { + list-style: decimal; + padding-inline-start: 1.5rem; + margin-block-end: 1rem; + } + .richtext a { + color: var(--color-link); + text-decoration: underline; + text-underline-offset: 2px; + } + .richtext a:hover { + color: var(--color-link-hover); + } + .richtext > *:first-child { + margin-top: 0; + } + .richtext > *:last-child { + margin-bottom: 0; + } + + .dialog-center { + border: none; + padding: 0; + margin: 0; + max-width: none; + max-height: none; + overflow: hidden; + background: var(--color-surface); + color: var(--color-on-surface); + box-shadow: var(--shadow-xl); + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + } + .dialog-center::backdrop { + background: var(--color-overlay-scrim); + } + @media (min-width: 48rem) { + .dialog-center { + inset-block-start: var(--spacing-search-modal-offset-block-start); + inset-block-end: auto; + inset-inline-start: 50%; + inset-inline-end: auto; + transform: translateX(-50%); + width: var(--spacing-search-modal-width); + height: auto; + max-height: var(--spacing-dialog-max-height); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + } + } + @media (prefers-reduced-motion: no-preference) and (min-width: 48rem) { + .dialog-center[open] { + animation: dialog-center-enter 150ms var(--ease-ui); + } + } + @keyframes dialog-center-enter { + from { + opacity: 0; + transform: translateX(-50%) scale(0.98); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } + } + + .drawer-right, + .drawer-left { + border: none; + padding: 0; + margin: 0; + max-width: none; + max-height: none; + overflow: hidden; + background: var(--color-surface); + box-shadow: var(--shadow-xl); + position: fixed; + inset-block: 0; + width: 100vw; + height: 100%; + } + .drawer-right { + inset-inline-start: auto; + inset-inline-end: 0; + } + .drawer-left { + inset-inline-start: 0; + inset-inline-end: auto; + } + .drawer-right::backdrop, + .drawer-left::backdrop { + background: rgb(0 0 0 / 0.5); + } + @media (min-width: 480px) { + .drawer-right, + .drawer-left { + width: var(--spacing-drawer-width); + } } } -/* Backdrop fade-in. The core sets the open backdrop color; here we make it - * start transparent so it can transition in (and back out on close). */ -.drawer-right::backdrop, -.drawer-left::backdrop { - background: rgb(0 0 0 / 0); +@utility bleed-full { + margin-inline: calc(-1 * var(--spacing-margin) - max(0px, (100vw - var(--spacing-page)) / 2)); + width: calc(100% + 2 * var(--spacing-margin) + max(0px, 100vw - var(--spacing-page))); +} + +@utility gallery-bleed-start { + margin-inline-start: calc( + -1 * var(--spacing-margin) - max(0px, (100vw - var(--spacing-page)) / 2) + ); + width: calc(100% + var(--spacing-margin) + max(0px, 100vw - var(--spacing-page)) / 2); +} + +/* Cart drawer dialog shell (hydrogen-cart-drawer skill reference CSS). */ +dialog#cart-drawer { + transform: translateX(100%); transition: - background-color 250ms ease-out, + transform 250ms cubic-bezier(0.22, 1, 0.36, 1), overlay 250ms allow-discrete, display 250ms allow-discrete; } -.drawer-right[open]::backdrop, -.drawer-left[open]::backdrop { - background: rgb(0 0 0 / 0.5); +dialog#cart-drawer[open] { + transform: translateX(0); } @starting-style { - .drawer-right[open]::backdrop, - .drawer-left[open]::backdrop { - background: rgb(0 0 0 / 0); + dialog#cart-drawer[open] { + transform: translateX(100%); } } -@media (prefers-reduced-motion: reduce) { - .drawer-right, - .drawer-left, - .drawer-right::backdrop, - .drawer-left::backdrop { - transition: none; +dialog#cart-drawer::backdrop { + background: rgb(0 0 0 / 0); + transition: + background-color 250ms ease-out, + overlay 250ms allow-discrete, + display 250ms allow-discrete; +} +dialog#cart-drawer[open]::backdrop { + background: rgb(0 0 0 / 0.3); +} + +@starting-style { + dialog#cart-drawer[open]::backdrop { + background: rgb(0 0 0 / 0); } } -/* Body scroll lock while any drawer is open — pure CSS, no JS class toggling. */ -body:has(.drawer-right[open]), -body:has(.drawer-left[open]) { +/* Body scroll lock while the cart drawer is open (pure CSS, no JS toggling). */ +body:has(dialog#cart-drawer[open]) { overflow: hidden; } diff --git a/templates/react-router/app/components/AnalyticsTracker.tsx b/templates/react-router/app/components/AnalyticsTracker.tsx new file mode 100644 index 0000000000..c1d0105b20 --- /dev/null +++ b/templates/react-router/app/components/AnalyticsTracker.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router"; + +import { AnalyticsEvent, addAnalyticsConsoleDestination, getAnalytics } from "../lib/analytics"; + +/** + * Root analytics tracker (`hydrogen-analytics` / `references/react.md`). + * Publishes page views through the global bus created by `ShopifyScripts`. + * Keys the effect by the framework location so client navigations fire a fresh + * page view (F9: no polling). + */ +export function AnalyticsTracker() { + const location = useLocation(); + const pageKey = `${location.pathname}?${location.search}`; + + useEffect(() => { + const cleanup = addAnalyticsConsoleDestination(); + return () => { + cleanup?.(); + }; + }, []); + + useEffect(() => { + const analytics = getAnalytics(); + if (!analytics) return; + analytics.publish(AnalyticsEvent.PAGE_VIEWED); + }, [pageKey]); + + return null; +} diff --git a/templates/react-router/app/components/AnalyticsTrackers.tsx b/templates/react-router/app/components/AnalyticsTrackers.tsx deleted file mode 100644 index 1fcab1caca..0000000000 --- a/templates/react-router/app/components/AnalyticsTrackers.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { AnalyticsCart, ConsentConfig, ShopAnalytics } from "@shopify/hydrogen"; -import { useCartAnalytics } from "@shopify/hydrogen/react"; -import { useEffect, useRef } from "react"; -import { useLocation } from "react-router"; - -import { - AnalyticsEvent, - configureAnalytics, - getAnalytics, - getAnalyticsShop, -} from "~/lib/analytics"; - -type AnalyticsTapWindow = Window & { - __analyticsEvents?: Array<{ event: string; payload: Record }>; -}; - -const TAP_EVENTS = [ - AnalyticsEvent.PAGE_VIEWED, - AnalyticsEvent.PRODUCT_VIEWED, - AnalyticsEvent.COLLECTION_VIEWED, - AnalyticsEvent.CART_VIEWED, - AnalyticsEvent.SEARCH_VIEWED, - AnalyticsEvent.CART_UPDATED, - AnalyticsEvent.PRODUCT_ADD_TO_CART, - AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, -] as const; - -export function AnalyticsTracker({ - shop, - consent, - enableTestTap, -}: { - shop: ShopAnalytics; - consent: ConsentConfig; - enableTestTap: boolean; -}) { - const location = useLocation(); - const pageKey = `${location.pathname}${location.search}`; - const tapConfigured = useRef(false); - - useEffect(() => { - configureAnalytics(shop, consent); - const analytics = getAnalytics(); - if (!analytics) return; - - if (enableTestTap && !tapConfigured.current) { - tapConfigured.current = true; - const win = window as AnalyticsTapWindow; - win.__analyticsEvents ??= []; - for (const event of TAP_EVENTS) { - analytics.subscribe(event, (payload) => { - win.__analyticsEvents?.push({ event, payload: payload as Record }); - }); - } - } - - analytics.publish(AnalyticsEvent.PAGE_VIEWED, { - url: window.location.href, - shop, - }); - }, [pageKey, shop, consent, enableTestTap]); - - return null; -} - -function toAnalyticsCart(cart: unknown): AnalyticsCart | null { - const candidate = cart as { - id?: string | null; - updatedAt?: string; - lines?: { - nodes?: Array<{ - id: string; - quantity: number; - cost?: { amountPerQuantity?: { amount: string; currencyCode?: string } }; - merchandise?: { - id?: string; - title?: string; - sku?: string | null; - product?: { - id?: string; - title?: string; - vendor?: string; - productType?: string; - handle?: string; - }; - }; - }>; - }; - }; - - if (!candidate.id || !candidate.updatedAt) return null; - - return { - id: candidate.id, - updatedAt: candidate.updatedAt, - lines: { - nodes: (candidate.lines?.nodes ?? []).flatMap((line) => { - const merchandise = line.merchandise; - const product = merchandise?.product; - const price = line.cost?.amountPerQuantity; - if (!merchandise?.id || !product?.id || !product.title || !product.vendor || !price) { - return []; - } - return [ - { - id: line.id, - quantity: line.quantity, - merchandise: { - id: merchandise.id, - title: merchandise.title ?? product.title, - sku: merchandise.sku, - price, - product: { - id: product.id, - title: product.title, - vendor: product.vendor, - productType: product.productType, - handle: product.handle, - }, - }, - }, - ]; - }), - }, - }; -} - -export function CartAnalyticsTracker() { - useCartAnalytics(); - return null; -} - -export function publishCartViewed(cart: unknown) { - const analytics = getAnalytics(); - if (!analytics) return; - analytics.publish(AnalyticsEvent.CART_VIEWED, { - cart: toAnalyticsCart(cart), - url: window.location.href, - shop: getAnalyticsShop(), - }); -} diff --git a/templates/react-router/app/components/Breadcrumbs.tsx b/templates/react-router/app/components/Breadcrumbs.tsx new file mode 100644 index 0000000000..a5b6b6315c --- /dev/null +++ b/templates/react-router/app/components/Breadcrumbs.tsx @@ -0,0 +1,51 @@ +import { Link } from "react-router"; + +export type Crumb = { + label: string; + href?: string; +}; + +type BreadcrumbsProps = { + items: Crumb[]; +}; + +/** + * Server-rendered breadcrumb trail. The last crumb is `aria-current="page"`. + * Reused by collection, collections, search, and product routes (F13). + */ +export function Breadcrumbs({ items }: BreadcrumbsProps) { + return ( + + ); +} diff --git a/templates/react-router/app/components/CartAnalyticsTracker.tsx b/templates/react-router/app/components/CartAnalyticsTracker.tsx new file mode 100644 index 0000000000..1b6422833e --- /dev/null +++ b/templates/react-router/app/components/CartAnalyticsTracker.tsx @@ -0,0 +1,10 @@ +import { useCartAnalytics } from "@shopify/hydrogen/react"; + +/** + * Subscribes to cart store changes and publishes cart analytics events from + * server-confirmed cart data. + */ +export function CartAnalyticsTracker() { + useCartAnalytics(); + return null; +} diff --git a/templates/react-router/app/components/CartContent.tsx b/templates/react-router/app/components/CartContent.tsx new file mode 100644 index 0000000000..d4e1c2ac97 --- /dev/null +++ b/templates/react-router/app/components/CartContent.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; + +import { useCart, useCartForm } from "~/lib/cart"; +import { content } from "~/lib/content"; +import { formatPrice } from "~/lib/money"; + +import { CartLineItem } from "./CartLineItem"; + +/** + * Shared cart content — line items, discount code form, and totals. Used by + * both the cart drawer and the `/cart` page (`hydrogen-cart-ui` / + * `hydrogen-cart-drawer`). The drawer wraps this in fixed header/body/footer + * zones; the page wraps it in a page layout. + * + * Layout: the line-item list flexes to fill the available height and scrolls on + * its own; the discount code + estimated-total block is pinned to the bottom + * (feedback: bottom-align the footer block). The order note was removed — its + * "save note" button was non-functional and the entry point is gone. + */ +export function CartContent() { + const cart = useCart((state) => state.data); + const loading = useCart((state) => state.loading); + const pending = useCart((state) => state.pending); + const networkErrors = useCart((state) => state.errors.network); + const isPending = pending.lines.size > 0 || pending.discountCodes.size > 0 || pending.note; + const lines = cart.lines.nodes; + const totalQuantity = cart.totalQuantity; + const subtotal = cart.cost.subtotalAmount; + const discountCodes = cart.discountCodes; + + const { formProps, register } = useCartForm(); + + if (loading) { + return ( +
+

Loading cart…

+
+ ); + } + + const isEmpty = totalQuantity === 0 || lines.length === 0; + + return ( + <> + + {isEmpty ? ( +
+

{content.cart.empty}

+

{content.cart.emptyDescription}

+
+ ) : ( +
+
    + {lines.map((line) => ( +
  • + +
  • + ))} +
+ +
+
+ {/* Discount apply form */} +
+ + +
+ + {/* Applied discount codes — each removal is its own form. Empty/ + falsy codes are filtered out so an empty apply never renders a + blank pill or collides on a `""` React key. */} + {discountCodes + .filter((code) => code.code) + .map((code, index) => ( +
+ + {code.code} + +
+ ))} + + {/* Estimated total */} +
+ + {content.cart.totalLabel} + {isPending ? : null} + + + {formatPrice(subtotal)} + +
+

+ {content.cart.taxesAndShippingAtCheckout} +

+
+
+
+ )} + + ); +} + +function CartStatus({ + isPending, + networkErrors, +}: { + isPending: boolean; + networkErrors: readonly { message: string }[]; +}) { + const message = useCartStatusMessage(isPending, networkErrors.length > 0); + return ( + <> + + {message} + + {networkErrors.length > 0 ? ( +
+ {networkErrors.map((error, index) => ( +

{error.message}

+ ))} +
+ ) : null} + + ); +} + +function useCartStatusMessage(isPending: boolean, hasNetworkErrors: boolean): string { + const [sawPending, setSawPending] = useState(false); + + useEffect(() => { + if (isPending) setSawPending(true); + }, [isPending]); + + if (isPending) return "Updating cart totals"; + if (sawPending && !hasNetworkErrors) return "Cart totals updated"; + return ""; +} diff --git a/templates/react-router/app/components/CartDrawer.tsx b/templates/react-router/app/components/CartDrawer.tsx index eb94436eb1..1addece314 100644 --- a/templates/react-router/app/components/CartDrawer.tsx +++ b/templates/react-router/app/components/CartDrawer.tsx @@ -1,296 +1,78 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router"; - -import { useCart, useCartForm } from "~/lib/cart"; -import { closeCartDrawer, configureOpenCartAction, CART_DRAWER_ID } from "~/lib/cart-drawer"; -import { formatPrice } from "~/lib/money"; - -import { publishCartViewed } from "./AnalyticsTrackers"; - -function CartErrorBanner() { - const errors = useCart((state) => state.errors); - const [dismissedAt, setDismissedAt] = useState(0); - const messages = useMemo(() => { - const lines = [...errors.lines.values()].flatMap((group) => group.userErrors); - return [...errors.network, ...errors.cart.userErrors, ...lines].map((error) => error.message); - }, [errors]); - - if (messages.length === 0 || errors.lastUpdatedAt <= dismissedAt) return null; - - return ( -
-
-
- {messages.map((message) => ( -

- {message} -

- ))} -
- -
-
- ); -} - -function hasPendingCost(pending: { - cost?: boolean; - discountCodes: Set; - lines: Set; -}): boolean { - return pending.cost ?? (pending.lines.size > 0 || pending.discountCodes.size > 0); -} - -function useCartStatusMessage(): string { - const pending = useCart((state) => state.pending); - const revalidating = useCart((state) => "revalidating" in state && state.revalidating === true); - const networkErrors = useCart((state) => state.errors.network); - const isPending = hasPendingCost(pending) || revalidating; - const [sawPending, setSawPending] = useState(false); - - useEffect(() => { - if (isPending) setSawPending(true); - }, [isPending]); - - if (isPending) return "Updating cart totals"; - if (sawPending && networkErrors.length === 0) return "Cart totals updated"; - return ""; -} - -type CartLineView = { - id: string; - quantity: number; - cost: { totalAmount: { amount: string; currencyCode: string } }; - merchandise?: { - title?: string | null; - selectedOptions?: Array<{ name: string; value: string }> | null; - image?: { url: string; altText?: string | null } | null; - product?: { title?: string | null; handle?: string | null } | null; - } | null; -}; +import { useCart } from "~/lib/cart"; +import { CART_DRAWER_ID, closeCartDrawer, configureOpenCartAction } from "~/lib/cart-drawer"; +import { content } from "~/lib/content"; + +import { CartContent } from "./CartContent"; + +/** + * Cart drawer — a native `` + `showModal()` rendered once in the root + * layout (`hydrogen-cart-drawer` skill). Opens via `openCartDrawer()`, + * `window.Shopify.actions.openCart()`, or after a successful add-to-cart. The + * `/cart` route is the no-JS fallback (F4). Uses fixed header/body/footer + * zones; the body scrolls, the footer is hidden when the cart is empty. + */ +export function CartDrawer() { + // Ensure the Standard Actions `openCart` handler is registered on the client. + configureOpenCartAction(); -export function CartLineItem({ line }: { line: CartLineView }) { - const { formProps, register } = useCartForm(); - const pendingLines = useCart((state) => state.pending.lines); - const lineError = useCart((state) => state.errors.lines.get(line.id)); - const merchandise = line.merchandise; - const product = merchandise?.product; - const pending = pendingLines.has(line.id); - const optionText = merchandise?.selectedOptions - ?.map((option: { name: string; value: string }) => option.value) - .join(" / "); - const errorId = `cart-line-error-${line.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const totalQuantity = useCart((state) => state.data.totalQuantity); + const isEmpty = totalQuantity === 0; + const checkoutUrl = useCart((state) => state.data.checkoutUrl); return ( -
  • -
    - {merchandise?.image ? ( - {merchandise.image.altText - ) : null} -
    -
    -

    - {product?.handle ? ( - - {product.title ?? merchandise?.title ?? "Product"} - - ) : ( - (product?.title ?? merchandise?.title ?? "Product") - )} -

    - {optionText ?

    {optionText}

    : null} -

    - {formatPrice(line.cost.totalAmount)} -

    -
    - - - + {content.cart.title}{" "} + + {totalQuantity} {totalQuantity === 1 ? "item" : "items"} + +
    - - {lineError?.userErrors.length ? ( - - ) : null} - -
  • - ); -} - -function CartLines() { - const loading = useCart((state) => state.loading); - const lines = useCart((state) => state.data.lines.nodes); - - if (loading) { - return

    Loading cart…

    ; - } - - if (lines.length === 0) { - return ( -
    -

    Your cart is empty.

    -

    - Looks like you haven't added anything to your cart yet. -

    -
    - ); - } - - return ( -
      - {lines.map((line) => ( - - ))} -
    - ); -} - -function CartFooter() { - const cart = useCart((state) => state.data); - const pending = useCart((state) => state.pending); - const revalidating = useCart((state) => "revalidating" in state && state.revalidating === true); - const hasLines = cart.lines.nodes.length > 0; - const isPending = hasPendingCost(pending) || revalidating; - - if (!hasLines) return null; - - return ( -
    -
    -
    - - Estimated total - {isPending ? : null} - - - {formatPrice(cart.cost.totalAmount)} -
    -

    - Taxes and shipping calculated at checkout -

    - {cart.checkoutUrl ? ( - - Checkout - - ) : null} -
    -
    - ); -} - -export function CartDrawer() { - const totalQuantity = useCart((state) => state.data.totalQuantity); - const cart = useCart((state) => state.data); - const statusMessage = useCartStatusMessage(); - useEffect(() => { - configureOpenCartAction(); - const dialog = document.getElementById(CART_DRAWER_ID); - if (!(dialog instanceof HTMLDialogElement)) return; - const handleToggle = () => { - if (dialog.open) publishCartViewed(cart); - }; - dialog.addEventListener("toggle", handleToggle); - return () => dialog.removeEventListener("toggle", handleToggle); - }, [cart]); +
    + +
    - return ( - <> - - {statusMessage} - - -
    -
    -
    -

    - Cart -

    - {totalQuantity} -
    - -
    - - - -
    -
    - + ) : null} + +
    ); } diff --git a/templates/react-router/app/components/CartLineItem.tsx b/templates/react-router/app/components/CartLineItem.tsx new file mode 100644 index 0000000000..8db66ed96e --- /dev/null +++ b/templates/react-router/app/components/CartLineItem.tsx @@ -0,0 +1,144 @@ +import { useCart, useCartForm } from "~/lib/cart"; +import { shopifyImageUrl } from "~/lib/image"; +import { formatPrice } from "~/lib/money"; + +/** + * A cart line, derived from the typed `useCart` binding (F3: consume typed data, + * no hand-rolled parallel shape). Narrowed field access stays tolerant of the + * gql.tada-inferred merchandise union via optional chaining + * (`hydrogen-cart-ui` / `references/react.md`). + */ +type CartState = Parameters[0]>[0]; +type CartLine = CartState["data"]["lines"]["nodes"][number]; + +/** + * Shared cart line-item form — used by both the cart drawer and the `/cart` + * page (`hydrogen-cart-ui` / `hydrogen-cart-drawer`). Preserves the + * progressive-enhancement form contract: hidden `register("set")`, scoped + * `register("lineId", { value })`, and a real editable quantity input from + * `register("quantity", { value, interactive: true })`. Increase/decrease/remove + * are additional submit controls, not replacements. + */ +export function CartLineItem({ line }: { line: CartLine }) { + const { formProps, register } = useCartForm(); + const pendingLines = useCart((state) => state.pending.lines); + const isPending = pendingLines.has(line.id); + + const merchandise = line.merchandise; + const productTitle = merchandise?.product?.title ?? merchandise?.title ?? "Product"; + const productHandle = merchandise?.product?.handle; + const selectedOptions = merchandise?.selectedOptions ?? []; + const variantSubtitle = selectedOptions.map((option) => option.value).join(" / "); + + const totalAmount = line.cost.totalAmount; + const compareAt = line.cost.compareAtAmountPerQuantity ?? null; + const onSale = compareAt && Number(compareAt.amount) > Number(totalAmount.amount); + + return ( +
    +
    + {merchandise?.image ? ( + {merchandise.image.altText + ) : null} +
    + +
    + {productHandle ? ( + + {productTitle} + + ) : ( +

    {productTitle}

    + )} + {variantSubtitle ? ( +

    {variantSubtitle}

    + ) : null} +

    + {onSale ? ( + {formatPrice(totalAmount)} + ) : ( + formatPrice(totalAmount) + )} + {onSale && compareAt ? ( + <> + {" "} + {formatPrice(compareAt)} + + ) : null} +

    + +
    + + + +
    + +
    + +
    window.setTimeout(focusNextCartControl, 0), + })} + > + + +
    + + ); +} + +function focusNextCartControl() { + const target = document.querySelector( + "[data-cart-line-control], [data-cart-empty], [data-cart-heading]", + ); + target?.focus(); +} diff --git a/templates/react-router/app/components/CollectionBrowse.tsx b/templates/react-router/app/components/CollectionBrowse.tsx deleted file mode 100644 index 4bb2dfc3e3..0000000000 --- a/templates/react-router/app/components/CollectionBrowse.tsx +++ /dev/null @@ -1,710 +0,0 @@ -import { - getFilterRemovalUrl, - getSortByValue, - isFilterInputActive, - serializeCollectionParams, - type CollectionState, - type MoneyV2, - type ProductFilter, -} from "@shopify/hydrogen"; -import { useCollection, useCollectionForm } from "@shopify/hydrogen/react"; -import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { Link, useFetcher, useLocation } from "react-router"; - -import { formatPrice } from "~/lib/money"; - -export type SortOption = { - label: string; - value: string; -}; - -export const COLLECTION_SORT_OPTIONS: SortOption[] = [ - { label: "Featured", value: getSortByValue("COLLECTION_DEFAULT", false) }, - { label: "Best selling", value: getSortByValue("BEST_SELLING", false) }, - { label: "Alphabetically, A–Z", value: getSortByValue("TITLE", false) }, - { label: "Alphabetically, Z–A", value: getSortByValue("TITLE", true) }, - { label: "Price, low to high", value: getSortByValue("PRICE", false) }, - { label: "Price, high to low", value: getSortByValue("PRICE", true) }, - { label: "Date, old to new", value: getSortByValue("CREATED", false) }, - { label: "Date, new to old", value: getSortByValue("CREATED", true) }, -]; - -export const SEARCH_SORT_OPTIONS: SortOption[] = [ - { label: "Relevance", value: getSortByValue("RELEVANCE", false) }, - { label: "Price, low to high", value: getSortByValue("PRICE", false) }, - { label: "Price, high to low", value: getSortByValue("PRICE", true) }, -]; - -export type BrowseFilterValue = { - id: string; - label: string; - count: number; - input?: string | null; - swatch?: { - color?: string | null; - image?: { - previewImage?: { - url?: string | null; - altText?: string | null; - } | null; - } | null; - } | null; -}; - -export type BrowseFilter = { - id: string; - label: string; - type?: string | null; - presentation?: string | null; - values: readonly BrowseFilterValue[]; -}; - -export type BrowsePageInfo = { - hasNextPage: boolean; - endCursor?: string | null; -}; - -type LoadMoreResponse = { - products: readonly T[]; - pageInfo: BrowsePageInfo; - dataSearch: string; -}; - -const FILTER_DRAWER_ID = "collection-filter-drawer"; -const PRICE_MIN_PARAM = "filter.v.price.gte"; -const PRICE_MAX_PARAM = "filter.v.price.lte"; - -function supportsDialogCommands(): boolean { - if (typeof HTMLButtonElement === "undefined") return false; - return ( - "command" in HTMLButtonElement.prototype && "commandForElement" in HTMLButtonElement.prototype - ); -} - -function openDialogFallback(id: string): void { - if (supportsDialogCommands() || typeof document === "undefined") return; - const dialog = document.getElementById(id); - if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal(); -} - -function closeDialog(id: string): void { - if (typeof document === "undefined") return; - const dialog = document.getElementById(id); - if (dialog instanceof HTMLDialogElement) dialog.close(); -} - -function requestFormSubmit(event: React.ChangeEvent) { - event.currentTarget.form?.requestSubmit(); -} - -function currentSortValue(state: CollectionState): string | undefined { - return state.sortKey ? getSortByValue(state.sortKey, state.reverse) : undefined; -} - -function filterValueInputParamEntries(input: string): Array<{ name: string; value: string }> { - let filter: ProductFilter; - try { - filter = JSON.parse(input) as ProductFilter; - } catch { - return []; - } - - return Array.from( - serializeCollectionParams({ filters: [filter], sortKey: undefined, reverse: false }), - ([name, value]) => ({ name, value }), - ); -} - -function hiddenInputsFromParams(params: URLSearchParams, exclude = new Set()) { - return Array.from(params).flatMap(([name, value], index) => { - if (exclude.has(name)) return []; - return ; - }); -} - -function activeFilterParams(state: CollectionState) { - return serializeCollectionParams({ filters: state.filters, sortKey: undefined, reverse: false }); -} - -function buildPathWithRemoval(basePath: string, removal: string): string { - if (removal === "?") return basePath; - - const [pathname, existingSearch = ""] = basePath.split("?"); - const params = new URLSearchParams(existingSearch); - const removalParams = new URLSearchParams(removal.startsWith("?") ? removal.slice(1) : removal); - - for (const [name, value] of removalParams) params.append(name, value); - - const search = params.toString(); - return search ? `${pathname}?${search}` : pathname; -} - -function priceFilter(state: CollectionState) { - return state.filters.find((filter) => filter.price != null)?.price; -} - -function money(amount: number, currencyCode: string): MoneyV2 { - return { amount: String(amount), currencyCode }; -} - -function describeFilter(filter: ProductFilter, currencyCode: string): string { - if (filter.available != null) return filter.available ? "In stock" : "Out of stock"; - if (filter.productType) return filter.productType; - if (filter.productVendor) return filter.productVendor; - if (filter.tag) return filter.tag; - if (filter.variantOption) return filter.variantOption.value ?? filter.variantOption.name; - if (filter.productMetafield) return filter.productMetafield.value ?? filter.productMetafield.key; - if (filter.variantMetafield) return filter.variantMetafield.value ?? filter.variantMetafield.key; - if (filter.taxonomyMetafield) return filter.taxonomyMetafield.value; - if (filter.category) return filter.category.id; - if (filter.price) { - const min = filter.price.min; - const max = filter.price.max; - if (min != null && max != null) { - return `${formatPrice(money(min, currencyCode))} – ${formatPrice(money(max, currencyCode))}`; - } - if (min != null) return `From ${formatPrice(money(min, currencyCode))}`; - if (max != null) return `Up to ${formatPrice(money(max, currencyCode))}`; - } - return "Filter"; -} - -function activeValueCount(filter: BrowseFilter, state: CollectionState): number { - if (filter.type === "PRICE_RANGE") return priceFilter(state) ? 1 : 0; - return filter.values.filter( - (value) => value.input && isFilterInputActive(state.filters, value.input), - ).length; -} - -function isSwatchFilter(filter: BrowseFilter): boolean { - return ( - filter.presentation === "SWATCH" || - filter.values.some((value) => value.swatch?.color || value.swatch?.image?.previewImage?.url) - ); -} - -function isMutuallyExclusive(filter: BrowseFilter, inputName: string): boolean { - return filter.type === "BOOLEAN" || inputName === "filter.v.availability"; -} - -function uncheckSiblings(input: HTMLInputElement) { - const form = input.form; - if (!form) return; - for (const candidate of form.querySelectorAll('input[type="checkbox"]')) { - if (candidate !== input && candidate.name === input.name) candidate.checked = false; - } -} - -function CheckIcon() { - return ( - - ); -} - -function FacetGroup({ - filter, - children, - state, -}: { - filter: BrowseFilter; - children: ReactNode; - state: CollectionState; -}) { - const selectedCount = activeValueCount(filter, state); - - return ( -
    - - - {filter.label} - {selectedCount > 0 ? ( - <> - - - {selectedCount} {selectedCount === 1 ? "selected" : "selected"} - - - ) : null} - - - -
    {children}
    -
    - ); -} - -function ListFacet({ filter, state }: { filter: BrowseFilter; state: CollectionState }) { - const values = filter.values.flatMap((value) => { - if (!value.input) return []; - const entries = filterValueInputParamEntries(value.input); - if (entries.length !== 1) return []; - const [{ name, value: paramValue }] = entries; - const isActive = isFilterInputActive(state.filters, value.input); - - return ( -
  • - -
  • - ); - }); - - if (values.length === 0) return null; - - return ( -
    - {filter.label} -
      {values}
    -
    - ); -} - -function PriceRangeFacet({ state }: { state: CollectionState }) { - const timer = useRef | null>(null); - const idPrefix = useId(); - const minId = `${idPrefix}-price-gte`; - const maxId = `${idPrefix}-price-lte`; - const activePrice = priceFilter(state); - - useEffect(() => { - return () => { - if (timer.current) clearTimeout(timer.current); - }; - }, []); - - return ( -
    -
    - - { - if (timer.current) clearTimeout(timer.current); - const form = event.currentTarget.form; - timer.current = setTimeout(() => form?.requestSubmit(), 350); - }} - /> -
    - to -
    - - { - if (timer.current) clearTimeout(timer.current); - const form = event.currentTarget.form; - timer.current = setTimeout(() => form?.requestSubmit(), 350); - }} - /> -
    -
    - ); -} - -function ColorSwatchFacet({ filter, state }: { filter: BrowseFilter; state: CollectionState }) { - const values = filter.values.flatMap((value) => { - if (!value.input) return []; - const entries = filterValueInputParamEntries(value.input); - if (entries.length !== 1) return []; - const [{ name, value: paramValue }] = entries; - const swatch = value.swatch; - const imageUrl = swatch?.image?.previewImage?.url; - const color = swatch?.color; - const style = { - ...(color ? { "--filter-swatch-color": color } : {}), - ...(imageUrl ? { backgroundImage: `url("${imageUrl}")` } : {}), - } as CSSProperties; - - return ( -
  • - -
  • - ); - }); - - if (values.length === 0) return null; - - return ( -
    - {filter.label} -
      {values}
    -
    - ); -} - -function FacetBody({ filter, state }: { filter: BrowseFilter; state: CollectionState }) { - if (filter.type === "PRICE_RANGE") return ; - if (isSwatchFilter(filter)) return ; - return ; -} - -export function Toolbar({ - countText, - defaultSortValue, - sortOptions, - extraHiddenInputs, - filterDrawerId = FILTER_DRAWER_ID, -}: { - countText: string; - defaultSortValue: string; - sortOptions: SortOption[]; - extraHiddenInputs?: ReactNode; - filterDrawerId?: string; -}) { - const state: CollectionState = useCollection(); - const { formProps } = useCollectionForm(); - const resolvedSortValue = currentSortValue(state) ?? defaultSortValue; - const hiddenParams = activeFilterParams(state); - - return ( -
    -
    - - - {countText} - -
    -
    - {hiddenInputsFromParams(hiddenParams)} - {extraHiddenInputs} - - -
    -
    - ); -} - -export function FacetForm({ - availableFilters, - extraHiddenInputs, - remountKey, -}: { - availableFilters: readonly BrowseFilter[]; - extraHiddenInputs?: ReactNode; - remountKey?: string; -}) { - const state: CollectionState = useCollection(); - const { formProps } = useCollectionForm(); - const serialized = serializeCollectionParams(state); - const sort = currentSortValue(state); - const isLoading = state.status === "loading"; - - return ( -
    - {sort ? : null} - {extraHiddenInputs} -
    -
    - {availableFilters.map((filter) => ( - - - - ))} -
    -
    - -
    - ); -} - -export function FilterDrawer({ - availableFilters, - extraHiddenInputs, - id = FILTER_DRAWER_ID, - remountKey, -}: { - availableFilters: readonly BrowseFilter[]; - extraHiddenInputs?: ReactNode; - id?: string; - remountKey?: string; -}) { - return ( - -
    -
    -

    - Filters -

    - -
    -
    - -
    -
    -
    - ); -} - -export function ActiveFilterChips({ - basePath, - clearAllTo, - currencyCode, -}: { - basePath: string; - clearAllTo: string; - currencyCode: string; -}) { - const state: CollectionState = useCollection(); - if (state.filters.length === 0) return null; - - const currentParams = serializeCollectionParams(state); - - return ( -
    - {state.filters.map((filter, index) => { - const label = describeFilter(filter, currencyCode); - const removal = getFilterRemovalUrl(currentParams, filter); - const to = buildPathWithRemoval(basePath, removal); - - return ( - - {label} - - - ); - })} - - Clear all - -
    - ); -} - -export function useLoadMore( - initialNodes: readonly T[], - initialPageInfo: BrowsePageInfo, - dataSearch: string, -) { - const fetcher = useFetcher(); - const [nodes, setNodes] = useState(initialNodes); - const [pageInfo, setPageInfo] = useState(initialPageInfo); - const requestedSearch = useRef(null); - const appendedSearches = useRef(new Set()); - - useEffect(() => { - setNodes(initialNodes); - setPageInfo(initialPageInfo); - requestedSearch.current = null; - appendedSearches.current.clear(); - }, [dataSearch, initialNodes, initialPageInfo]); - - useEffect(() => { - const data = fetcher.data as LoadMoreResponse | undefined; - if (!data || data.dataSearch !== requestedSearch.current) return; - if (appendedSearches.current.has(data.dataSearch)) return; - - appendedSearches.current.add(data.dataSearch); - requestedSearch.current = null; - setNodes((current) => [...current, ...data.products]); - setPageInfo(data.pageInfo); - }, [fetcher.data]); - - return { - nodes, - pageInfo, - isLoading: fetcher.state !== "idle", - loadMore: (href: string, nextDataSearch: string) => { - requestedSearch.current = nextDataSearch; - fetcher.load(href); - }, - }; -} - -export function LoadMore({ - pageInfo, - loadedCount, - countLabel, - isLoading, - onLoad, -}: { - pageInfo: BrowsePageInfo; - loadedCount: number; - countLabel?: string; - isLoading: boolean; - onLoad: (href: string, nextDataSearch: string) => void; -}) { - const location = useLocation(); - - if (!pageInfo.hasNextPage || !pageInfo.endCursor) return null; - - const params = new URLSearchParams(location.search); - params.set("after", pageInfo.endCursor); - const nextSearch = params.toString(); - const href = `${location.pathname}?${nextSearch}`; - - return ( -
    -

    - {countLabel ?? `Showing ${loadedCount} products`} -

    - { - event.preventDefault(); - if (!isLoading) onLoad(href, nextSearch); - }} - > - {isLoading ? "Loading…" : "Load more"} - -
    - ); -} diff --git a/templates/react-router/app/components/CollectionCard.tsx b/templates/react-router/app/components/CollectionCard.tsx index 8df3513c1e..30dca0db9b 100644 --- a/templates/react-router/app/components/CollectionCard.tsx +++ b/templates/react-router/app/components/CollectionCard.tsx @@ -1,114 +1,50 @@ -import { gql, type StorefrontApi } from "@shopify/hydrogen"; +import type { StorefrontApi } from "@shopify/hydrogen"; import { Link } from "react-router"; -export const COLLECTION_CARD_PRODUCT_COUNT_LIMIT = 100; +import { COLLECTION_CARD_QUERY } from "~/lib/fragments"; +import { shopifyImageUrl, srcSetFor } from "~/lib/image"; -export const COLLECTION_CARD_FRAGMENT = gql(` - fragment CollectionCard on Collection { - handle - title - image { - url - altText - width - height - } - products(first: 1) { - nodes { - featuredImage { - url - altText - } - } - } - productCountProbe: products(first: 100) { - nodes { - id - } - pageInfo { - hasNextPage - } - } - } -`); +/** The typed collection card node. */ +export type CollectionCardData = NonNullable< + StorefrontApi.ResultOf["collection"] +>; -const COLLECTION_CARD_SHAPE_QUERY = gql( - `query CollectionCardShape { collections(first: 1) { nodes { ...CollectionCard } } }`, - [COLLECTION_CARD_FRAGMENT], -); - -export type CollectionCardData = StorefrontApi.ResultOf< - typeof COLLECTION_CARD_SHAPE_QUERY ->["collections"]["nodes"][number]; - -export type CollectionCardProps = { +type CollectionCardProps = { collection: CollectionCardData; - priority?: boolean; - productCount?: number; - useProductImageFallback?: boolean; + loading?: "eager" | "lazy"; + fetchPriority?: "high" | "low" | "auto"; }; -function productCountText(collection: CollectionCardData, productCount?: number) { - if (typeof productCount === "number") { - return `${productCount} ${productCount === 1 ? "product" : "products"}`; - } - - if (collection.productCountProbe.pageInfo.hasNextPage) { - return `${COLLECTION_CARD_PRODUCT_COUNT_LIMIT}+ products`; - } - - const count = collection.productCountProbe.nodes.length; - return `${count} ${count === 1 ? "product" : "products"}`; -} - export function CollectionCard({ collection, - priority = false, - productCount, - useProductImageFallback = true, + loading = "lazy", + fetchPriority = "auto", }: CollectionCardProps) { - const fallbackImage = useProductImageFallback - ? (collection.products.nodes[0]?.featuredImage ?? null) - : null; - const image = collection.image ?? fallbackImage; - const imageWidth = collection.image?.width ?? undefined; - const imageHeight = collection.image?.height ?? undefined; + const image = collection.image ?? collection.products.nodes[0]?.featuredImage; + const alt = collection.image?.altText ?? collection.title; return ( -
    -
    +
    +
    {image ? ( -
    - {image.altText -
    + {alt} ) : null}
    -
    -

    - - {collection.title} - -

    -

    - {productCountText(collection, productCount)} -

    + +
    +

    {collection.title}

    ); diff --git a/templates/react-router/app/components/ConsentBanner.tsx b/templates/react-router/app/components/ConsentBanner.tsx index 9afbbc33e3..5e4297f3b0 100644 --- a/templates/react-router/app/components/ConsentBanner.tsx +++ b/templates/react-router/app/components/ConsentBanner.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; +import { content } from "~/lib/content"; + type ConsentChoice = { analytics: boolean; marketing: boolean; @@ -7,145 +9,84 @@ type ConsentChoice = { sale_of_data: boolean; }; -type CustomerPrivacy = { +type CustomerPrivacyApi = { + setTrackingConsent?: (choice: ConsentChoice, callback: () => void) => void; shouldShowBanner?: () => boolean; - setTrackingConsent?: (choice: ConsentChoice, callback?: () => void) => void; }; -function customerPrivacy(): CustomerPrivacy | undefined { - return window.Shopify?.customerPrivacy as CustomerPrivacy | undefined; -} +const CONSENT_API_RETRY_DELAY_MS = 100; +const CONSENT_API_MAX_RETRIES = 50; -function recordConsent(choice: ConsentChoice, done: () => void) { - const setTrackingConsent = customerPrivacy()?.setTrackingConsent; - if (!setTrackingConsent) { - done(); +function setTrackingConsent(choice: ConsentChoice, afterSave: () => void) { + const customerPrivacy: CustomerPrivacyApi | undefined = window.Shopify?.customerPrivacy; + + if (!customerPrivacy?.setTrackingConsent) { + afterSave(); return; } - setTrackingConsent(choice, done); -} -const allConsent: ConsentChoice = { - analytics: true, - marketing: true, - preferences: true, - sale_of_data: true, -}; - -const noConsent: ConsentChoice = { - analytics: false, - marketing: false, - preferences: false, - sale_of_data: false, -}; + customerPrivacy.setTrackingConsent(choice, afterSave); +} -export function ConsentBanner({ forceShow }: { forceShow: boolean }) { - const [visible, setVisible] = useState(forceShow); - const [managing, setManaging] = useState(false); - const [choice, setChoice] = useState({ - analytics: true, - marketing: false, - preferences: true, - sale_of_data: false, - }); +export function ConsentBanner() { + const [visible, setVisible] = useState(false); + const [dismissed, setDismissed] = useState(false); useEffect(() => { - if (forceShow) { - setVisible(true); - return; - } + let attempts = 0; + let timeoutId: number | undefined; - let cancelled = false; - const decide = () => { - if (cancelled) return true; - const shouldShowBanner = customerPrivacy()?.shouldShowBanner; - if (!shouldShowBanner) return false; - setVisible(Boolean(shouldShowBanner())); - return true; - }; + const checkVisibility = () => { + const customerPrivacy: CustomerPrivacyApi | undefined = window.Shopify?.customerPrivacy; + if (customerPrivacy?.shouldShowBanner) { + setVisible(customerPrivacy.shouldShowBanner()); + return; + } - if (decide()) return; - const timer = window.setInterval(() => { - if (decide()) window.clearInterval(timer); - }, 250); + attempts += 1; + if (attempts < CONSENT_API_MAX_RETRIES) { + timeoutId = window.setTimeout(checkVisibility, CONSENT_API_RETRY_DELAY_MS); + } + }; + checkVisibility(); return () => { - cancelled = true; - window.clearInterval(timer); + if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; - }, [forceShow]); + }, []); - if (!visible) return null; + if (dismissed || !visible) return null; - const hide = () => setVisible(false); + const save = (choice: ConsentChoice) => { + setTrackingConsent(choice, () => setDismissed(true)); + }; return ( - +
    ); } diff --git a/templates/react-router/app/components/Footer.tsx b/templates/react-router/app/components/Footer.tsx index 9d534a17c1..7bba72f569 100644 --- a/templates/react-router/app/components/Footer.tsx +++ b/templates/react-router/app/components/Footer.tsx @@ -1,38 +1,47 @@ import { Link } from "react-router"; -const linkClass = +import { content } from "~/lib/content"; + +const footerLinkClass = "min-h-touch-target text-on-surface-secondary hover:text-on-surface focus-visible:outline-accent inline-flex items-center font-normal no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-colors"; -export function Footer() { +export function Footer({ shopName = "CORE" }: { shopName?: string }) { return (