From cb39ee80d5d13b77b29d8ce48d4845bccbe28cac Mon Sep 17 00:00:00 2001 From: liaoyio <2417276459@qq.com> Date: Sat, 18 Jul 2026 15:23:53 +0800 Subject: [PATCH] Add a TanStack Start Hydrogen storefront example --- .oxlintrc.json | 1 + README.md | 1 + examples/README.md | 1 + examples/tanstack-start/.gitignore | 12 + examples/tanstack-start/app/app.css | 946 ++++ .../app/components/AnalyticsTracker.tsx | 33 + .../app/components/Breadcrumbs.tsx | 59 + .../app/components/CartAnalyticsTracker.tsx | 31 + .../app/components/CartContent.tsx | 110 + .../app/components/CartDrawer.tsx | 75 + .../app/components/CartLineItem.tsx | 130 + .../app/components/CollectionCard.tsx | 63 + .../app/components/ConsentBanner.tsx | 71 + .../tanstack-start/app/components/Footer.tsx | 117 + .../tanstack-start/app/components/Header.tsx | 278 ++ .../app/components/PredictiveSearchModal.tsx | 209 + .../app/components/ProductCard.tsx | 106 + .../app/components/QuantityStepper.tsx | 85 + examples/tanstack-start/app/lib/analytics.ts | 45 + .../tanstack-start/app/lib/cart-drawer.ts | 60 + .../tanstack-start/app/lib/cart-handlers.ts | 43 + examples/tanstack-start/app/lib/cart.ts | 11 + examples/tanstack-start/app/lib/content.ts | 148 + .../app/lib/customer-account.ts | 46 + .../app/lib/customer-session-handlers.ts | 16 + examples/tanstack-start/app/lib/filters.tsx | 280 ++ examples/tanstack-start/app/lib/fragments.ts | 125 + examples/tanstack-start/app/lib/image.ts | 61 + examples/tanstack-start/app/lib/money.ts | 18 + .../app/lib/predictive-search-handlers.ts | 11 + .../tanstack-start/app/lib/product-query.ts | 111 + examples/tanstack-start/app/lib/product.ts | 10 + .../tanstack-start/app/lib/route-templates.ts | 8 + .../tanstack-start/app/lib/search-params.ts | 19 + examples/tanstack-start/app/lib/site.ts | 27 + .../app/lib/storefront-cache.ts | 19 + .../app/lib/storefront-context.server.ts | 120 + .../app/lib/storefront-context.ts | 36 + .../app/lib/storefront-middleware.ts | 29 + examples/tanstack-start/app/root.tsx | 222 + examples/tanstack-start/app/routeTree.gen.ts | 252 + examples/tanstack-start/app/router.tsx | 37 + examples/tanstack-start/app/routes.ts | 16 + .../tanstack-start/app/routes/account.tsx | 252 + examples/tanstack-start/app/routes/cart.tsx | 74 + .../tanstack-start/app/routes/catchall.tsx | 38 + .../tanstack-start/app/routes/collection.tsx | 378 ++ .../tanstack-start/app/routes/collections.tsx | 107 + examples/tanstack-start/app/routes/home.tsx | 189 + .../tanstack-start/app/routes/product.tsx | 454 ++ examples/tanstack-start/app/routes/robots.tsx | 31 + examples/tanstack-start/app/routes/search.tsx | 338 ++ .../tanstack-start/app/routes/sitemap.tsx | 101 + examples/tanstack-start/app/server.ts | 14 + examples/tanstack-start/app/start.ts | 11 + examples/tanstack-start/package.json | 38 + examples/tanstack-start/public/favicon.svg | 10 + .../tanstack-start/public/icons/icon-cart.svg | 1 + .../public/icons/icon-chevron-down.svg | 3 + .../public/icons/icon-chevron-left.svg | 3 + .../public/icons/icon-chevron-right.svg | 3 + .../public/icons/icon-filter.svg | 1 + .../tanstack-start/public/icons/icon-menu.svg | 4 + .../public/icons/icon-minus.svg | 3 + .../tanstack-start/public/icons/icon-plus.svg | 4 + .../public/icons/icon-search.svg | 1 + .../public/icons/icon-trash.svg | 10 + .../tanstack-start/public/icons/icon-user.svg | 1 + .../tanstack-start/public/icons/icon-x.svg | 3 + examples/tanstack-start/tsconfig.json | 40 + examples/tanstack-start/tsr.config.json | 7 + examples/tanstack-start/vite.config.ts | 33 + oxfmt.config.ts | 2 + pnpm-lock.yaml | 4068 ++++++++++------- 74 files changed, 8773 insertions(+), 1547 deletions(-) create mode 100644 examples/tanstack-start/.gitignore create mode 100644 examples/tanstack-start/app/app.css create mode 100644 examples/tanstack-start/app/components/AnalyticsTracker.tsx create mode 100644 examples/tanstack-start/app/components/Breadcrumbs.tsx create mode 100644 examples/tanstack-start/app/components/CartAnalyticsTracker.tsx create mode 100644 examples/tanstack-start/app/components/CartContent.tsx create mode 100644 examples/tanstack-start/app/components/CartDrawer.tsx create mode 100644 examples/tanstack-start/app/components/CartLineItem.tsx create mode 100644 examples/tanstack-start/app/components/CollectionCard.tsx create mode 100644 examples/tanstack-start/app/components/ConsentBanner.tsx create mode 100644 examples/tanstack-start/app/components/Footer.tsx create mode 100644 examples/tanstack-start/app/components/Header.tsx create mode 100644 examples/tanstack-start/app/components/PredictiveSearchModal.tsx create mode 100644 examples/tanstack-start/app/components/ProductCard.tsx create mode 100644 examples/tanstack-start/app/components/QuantityStepper.tsx create mode 100644 examples/tanstack-start/app/lib/analytics.ts create mode 100644 examples/tanstack-start/app/lib/cart-drawer.ts create mode 100644 examples/tanstack-start/app/lib/cart-handlers.ts create mode 100644 examples/tanstack-start/app/lib/cart.ts create mode 100644 examples/tanstack-start/app/lib/content.ts create mode 100644 examples/tanstack-start/app/lib/customer-account.ts create mode 100644 examples/tanstack-start/app/lib/customer-session-handlers.ts create mode 100644 examples/tanstack-start/app/lib/filters.tsx create mode 100644 examples/tanstack-start/app/lib/fragments.ts create mode 100644 examples/tanstack-start/app/lib/image.ts create mode 100644 examples/tanstack-start/app/lib/money.ts create mode 100644 examples/tanstack-start/app/lib/predictive-search-handlers.ts create mode 100644 examples/tanstack-start/app/lib/product-query.ts create mode 100644 examples/tanstack-start/app/lib/product.ts create mode 100644 examples/tanstack-start/app/lib/route-templates.ts create mode 100644 examples/tanstack-start/app/lib/search-params.ts create mode 100644 examples/tanstack-start/app/lib/site.ts create mode 100644 examples/tanstack-start/app/lib/storefront-cache.ts create mode 100644 examples/tanstack-start/app/lib/storefront-context.server.ts create mode 100644 examples/tanstack-start/app/lib/storefront-context.ts create mode 100644 examples/tanstack-start/app/lib/storefront-middleware.ts create mode 100644 examples/tanstack-start/app/root.tsx create mode 100644 examples/tanstack-start/app/routeTree.gen.ts create mode 100644 examples/tanstack-start/app/router.tsx create mode 100644 examples/tanstack-start/app/routes.ts create mode 100644 examples/tanstack-start/app/routes/account.tsx create mode 100644 examples/tanstack-start/app/routes/cart.tsx create mode 100644 examples/tanstack-start/app/routes/catchall.tsx create mode 100644 examples/tanstack-start/app/routes/collection.tsx create mode 100644 examples/tanstack-start/app/routes/collections.tsx create mode 100644 examples/tanstack-start/app/routes/home.tsx create mode 100644 examples/tanstack-start/app/routes/product.tsx create mode 100644 examples/tanstack-start/app/routes/robots.tsx create mode 100644 examples/tanstack-start/app/routes/search.tsx create mode 100644 examples/tanstack-start/app/routes/sitemap.tsx create mode 100644 examples/tanstack-start/app/server.ts create mode 100644 examples/tanstack-start/app/start.ts create mode 100644 examples/tanstack-start/package.json create mode 100644 examples/tanstack-start/public/favicon.svg create mode 100644 examples/tanstack-start/public/icons/icon-cart.svg create mode 100644 examples/tanstack-start/public/icons/icon-chevron-down.svg create mode 100644 examples/tanstack-start/public/icons/icon-chevron-left.svg create mode 100644 examples/tanstack-start/public/icons/icon-chevron-right.svg create mode 100644 examples/tanstack-start/public/icons/icon-filter.svg create mode 100644 examples/tanstack-start/public/icons/icon-menu.svg create mode 100644 examples/tanstack-start/public/icons/icon-minus.svg create mode 100644 examples/tanstack-start/public/icons/icon-plus.svg create mode 100644 examples/tanstack-start/public/icons/icon-search.svg create mode 100644 examples/tanstack-start/public/icons/icon-trash.svg create mode 100644 examples/tanstack-start/public/icons/icon-user.svg create mode 100644 examples/tanstack-start/public/icons/icon-x.svg create mode 100644 examples/tanstack-start/tsconfig.json create mode 100644 examples/tanstack-start/tsr.config.json create mode 100644 examples/tanstack-start/vite.config.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index f832c1f071..191a23b4fe 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -18,6 +18,7 @@ "**/.output/**", "**/.astro/**", "**/.vinxi/**", + "**/.tanstack/**", "**/.react-router/**", "**/node_modules/**", "patches/**", diff --git a/README.md b/README.md index b5f756ac25..3a33a0e73f 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ The [`examples/`](./examples) directory ports the same storefront across framewo | --- | --- | | `nextjs/` | Next.js 16 (App Router) | | `react-router/` | React Router v7, server loaders | +| `tanstack-start/` | TanStack Start, server functions | | `sveltekit/` | SvelteKit 2 + Svelte 5 (runes) | | `astro/` | Astro 6 SSR | | `solid-start/` | SolidStart v1 | diff --git a/examples/README.md b/examples/README.md index 120735b006..999f87c3a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ - `core/` — frozen, framework-agnostic storefront design source (five-page reference HTML + Tailwind tokens, no app JS). Framework examples are hand-built from this baseline. - `shared/` — common example configuration and request helpers - `react-router/` — React Router v7 port with server loaders +- `tanstack-start/` — TanStack Start port with server functions and a request-scoped Hydrogen context - `nextjs/` — Next.js 16 (App Router) port with server components - `hydrogen/` — Hydrogen port with React Router server loaders and Oxygen-style request context - `sveltekit/` — SvelteKit 2 + Svelte 5 (runes) port with server `load` diff --git a/examples/tanstack-start/.gitignore b/examples/tanstack-start/.gitignore new file mode 100644 index 0000000000..23cccb47a2 --- /dev/null +++ b/examples/tanstack-start/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.output/ +.tanstack/ +.nitro/ +.vinxi/ +dist/ +.turbo/ +tsconfig.tsbuildinfo +*.log +.DS_Store +storefront-graphql-env.d.ts +customer-account-graphql-env.d.ts diff --git a/examples/tanstack-start/app/app.css b/examples/tanstack-start/app/app.css new file mode 100644 index 0000000000..a7c6c7325f --- /dev/null +++ b/examples/tanstack-start/app/app.css @@ -0,0 +1,946 @@ +@import "tailwindcss"; + +/* + 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 TanStack Start 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: #6b7280; + --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; + + /* 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; +} + +: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; +} + +@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; + } + + 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; + } + } +} + +@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: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; + } + @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:active { + background: color-mix(in oklab, var(--color-surface-secondary) 88%, black); + } + + .quantity-selector-outlined { + border: none; + background: transparent; + } + .quantity-selector-outlined:focus-within { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + .quantity-selector-outlined button:focus-visible { + outline: none; + } + .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; + } + } +} + +@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); + } + + .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-accent); + text-decoration: underline; + text-underline-offset: 2px; + } + .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); + } + } +} + +@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: + transform 250ms cubic-bezier(0.22, 1, 0.36, 1), + overlay 250ms allow-discrete, + display 250ms allow-discrete; +} + +dialog#cart-drawer[open] { + transform: translateX(0); +} + +@starting-style { + dialog#cart-drawer[open] { + transform: translateX(100%); + } +} + +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 the cart drawer is open (pure CSS, no JS toggling). */ +body:has(dialog#cart-drawer[open]) { + overflow: hidden; +} diff --git a/examples/tanstack-start/app/components/AnalyticsTracker.tsx b/examples/tanstack-start/app/components/AnalyticsTracker.tsx new file mode 100644 index 0000000000..45c0add32f --- /dev/null +++ b/examples/tanstack-start/app/components/AnalyticsTracker.tsx @@ -0,0 +1,33 @@ +import type { ConsentConfig, ShopAnalytics } from "@shopify/hydrogen"; +import { useLocation } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { AnalyticsEvent, configureAnalytics, getAnalytics } from "~/lib/analytics"; + +/** + * Root analytics tracker (`hydrogen-analytics` / `references/react.md`). + * Resolves the safe `ShopAnalytics` + `ConsentConfig` objects on the server + * root and configures the client singleton before publishing. Keys the + * `page_viewed` effect by the framework location so client navigations fire a + * fresh page view (F9: no polling). + */ +export function AnalyticsTracker({ + shop, + consent, +}: { + shop: ShopAnalytics; + consent: ConsentConfig; +}) { + const pageKey = useLocation({ + select: (location) => `${location.pathname}${location.searchStr}`, + }); + + useEffect(() => { + configureAnalytics(shop, consent); + const analytics = getAnalytics(); + if (!analytics) return; + analytics.publish(AnalyticsEvent.PAGE_VIEWED); + }, [pageKey, shop, consent]); + + return null; +} diff --git a/examples/tanstack-start/app/components/Breadcrumbs.tsx b/examples/tanstack-start/app/components/Breadcrumbs.tsx new file mode 100644 index 0000000000..2efa4bdd4e --- /dev/null +++ b/examples/tanstack-start/app/components/Breadcrumbs.tsx @@ -0,0 +1,59 @@ +import { Link } from "@tanstack/react-router"; + +export type Crumb = { + label: string; + to?: "/collections"; +}; + +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/examples/tanstack-start/app/components/CartAnalyticsTracker.tsx b/examples/tanstack-start/app/components/CartAnalyticsTracker.tsx new file mode 100644 index 0000000000..bb7b80ec6c --- /dev/null +++ b/examples/tanstack-start/app/components/CartAnalyticsTracker.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from "react"; + +import { getAnalytics } from "~/lib/analytics"; +import { useCart } from "~/lib/cart"; + +/** + * Subscribes to cart store changes and forwards server-confirmed cart data to + * `analytics.updateCart()` (`hydrogen-analytics`). The bus derives + * `cart_updated` / `product_added_to_cart` / `product_removed_to_cart` from + * cart deltas — app code should never publish those manually. + * + * The cart query includes `updatedAt` (see `app/lib/cart-handlers.ts`), which + * the bus uses for dedupe. Without it, every `updateCart()` call is ignored. + */ +export function CartAnalyticsTracker() { + const cart = useCart((state) => state.data); + const prevUpdatedAt = useRef(null); + + useEffect(() => { + const analytics = getAnalytics(); + if (!analytics) return; + // Only update when the cart has a usable id + updatedAt (the bus ignores + // carts without updatedAt). + if (!cart.id || !cart.updatedAt) return; + if (prevUpdatedAt.current === cart.updatedAt) return; + prevUpdatedAt.current = cart.updatedAt; + analytics.updateCart(cart); + }, [cart]); + + return null; +} diff --git a/examples/tanstack-start/app/components/CartContent.tsx b/examples/tanstack-start/app/components/CartContent.tsx new file mode 100644 index 0000000000..915e7a5039 --- /dev/null +++ b/examples/tanstack-start/app/components/CartContent.tsx @@ -0,0 +1,110 @@ +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 lines = cart.lines.nodes; + const totalQuantity = cart.totalQuantity; + const total = cart.cost.totalAmount; + const discountCodes = cart.discountCodes; + + const { formProps, register } = useCartForm(); + + if (loading) { + return ( +
+

Loading cart…

+
+ ); + } + + if (totalQuantity === 0 || lines.length === 0) { + return ( +
+

{content.cart.empty}

+

{content.cart.emptyDescription}

+
+ ); + } + + return ( +
+ + +
+
+ {/* Discount apply form */} +
+ + +
+ + {/* Applied discount codes — each removal is its own form */} + {discountCodes.map((code) => ( +
+ + {code.code} + +
+ ))} + + {/* Estimated total */} +
+ {content.cart.totalLabel} + {formatPrice(total)} +
+

+ {content.cart.taxesAndShippingAtCheckout} +

+
+
+
+ ); +} diff --git a/examples/tanstack-start/app/components/CartDrawer.tsx b/examples/tanstack-start/app/components/CartDrawer.tsx new file mode 100644 index 0000000000..7f30abd1f8 --- /dev/null +++ b/examples/tanstack-start/app/components/CartDrawer.tsx @@ -0,0 +1,75 @@ +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(); + + const totalQuantity = useCart((state) => state.data.totalQuantity); + const isEmpty = totalQuantity === 0; + const checkoutUrl = useCart((state) => state.data.checkoutUrl); + + return ( + +
+
+
+

+ {content.cart.title} +

+ {!isEmpty ? ( + + ) : null} +
+ +
+ +
+ +
+ + {!isEmpty && checkoutUrl ? ( +
+ + {content.cart.checkout} + +
+ ) : null} +
+
+ ); +} diff --git a/examples/tanstack-start/app/components/CartLineItem.tsx b/examples/tanstack-start/app/components/CartLineItem.tsx new file mode 100644 index 0000000000..c0fc9d20cb --- /dev/null +++ b/examples/tanstack-start/app/components/CartLineItem.tsx @@ -0,0 +1,130 @@ +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(line.cost.amountPerQuantity.amount); + + return ( +
+
+ {merchandise?.image ? ( + {merchandise.image.altText + ) : null} +
+ +
+ {productHandle ? ( + + {productTitle} + + ) : ( +

{productTitle}

+ )} + {variantSubtitle ? ( +

{variantSubtitle}

+ ) : null} +

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

+ +
+ + + +
+ +
+ +
+ + +
+ + ); +} diff --git a/examples/tanstack-start/app/components/CollectionCard.tsx b/examples/tanstack-start/app/components/CollectionCard.tsx new file mode 100644 index 0000000000..42e38535de --- /dev/null +++ b/examples/tanstack-start/app/components/CollectionCard.tsx @@ -0,0 +1,63 @@ +import type { StorefrontApi } from "@shopify/hydrogen"; +import { Link } from "@tanstack/react-router"; + +import { COLLECTION_CARD_QUERY } from "~/lib/fragments"; +import { shopifyImageUrl, srcSetFor } from "~/lib/image"; + +/** The typed collection card node. */ +export type CollectionCardData = NonNullable< + StorefrontApi.ResultOf["collection"] +>; + +type CollectionCardProps = { + collection: CollectionCardData; + loading?: "eager" | "lazy"; + fetchPriority?: "high" | "low" | "auto"; +}; + +/** + * Shared `CollectionCard` — an overlay tile (square image, `overlay-dark` + * gradient, title-only, stretched link). Reused by the collections index and + * the home "shop by category" grid (engineering.md F13). Title-only — no + * product count (F5: the Storefront API has no cheap collection count). + * + * The `.card-link` is a direct child of the relative `.card` `
` so its + * `::after` stretched hit area covers the whole card, not just the caption. + * The caption is `pointer-events-none` so clicks over the title route to the + * card link. + */ +export function CollectionCard({ + collection, + loading = "lazy", + fetchPriority = "auto", +}: CollectionCardProps) { + const image = collection.image ?? collection.products.nodes[0]?.featuredImage; + const alt = collection.image?.altText ?? collection.title; + + return ( +
+
+ {image ? ( + {alt} + ) : null} +
+
+ +
+

{collection.title}

+
+
+ ); +} diff --git a/examples/tanstack-start/app/components/ConsentBanner.tsx b/examples/tanstack-start/app/components/ConsentBanner.tsx new file mode 100644 index 0000000000..1ab795b7b2 --- /dev/null +++ b/examples/tanstack-start/app/components/ConsentBanner.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; + +import { content } from "~/lib/content"; + +const CONSENT_STORAGE_KEY = "core-consent-choice"; + +type ConsentChoice = "accepted" | "declined"; + +/** + * Cookie / consent banner — the one deliberate JavaScript-only exception to the + * whole-site no-JS contract (`notes/consent-banner.md` + engineering.md F4 + * Known-deferred). It gates nothing else: with JS disabled it does not render, + * and that is acceptable because there is no consent to capture and no + * analytics to gate. The banner's `mode: "default-banner"` analytics consent is + * handled by the analytics bus (`hydrogen-analytics`); this is the app-owned + * dismiss/persist UI layered on top. + */ +export function ConsentBanner() { + const [choice, setChoice] = useState(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + try { + const stored = localStorage.getItem(CONSENT_STORAGE_KEY); + if (stored === "accepted" || stored === "declined") { + setChoice(stored); + } + } catch { + // localStorage may be unavailable; treat as no prior choice. + } + }, []); + + if (!mounted || choice !== null) return null; + + const persist = (value: ConsentChoice) => { + setChoice(value); + try { + localStorage.setItem(CONSENT_STORAGE_KEY, value); + } catch { + // Ignore storage failures. + } + }; + + return ( +
+

+ {content.consent.message}{" "} + + {content.consent.privacyPolicy} + +

+
+ + +
+
+ ); +} diff --git a/examples/tanstack-start/app/components/Footer.tsx b/examples/tanstack-start/app/components/Footer.tsx new file mode 100644 index 0000000000..5805744ffc --- /dev/null +++ b/examples/tanstack-start/app/components/Footer.tsx @@ -0,0 +1,117 @@ +import { Link } from "@tanstack/react-router"; + +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"; + +/** + * Site footer — shared chrome, server-rendered. Includes the `/cart` link that + * is the drawer's reachable no-JS fallback on every page (engineering.md F4 + + * `notes/cart.md` "Without JavaScript"). + */ +export function Footer() { + return ( +
+
+
+

CORE

+

© 2026 CORE

+
+ + +
+

+ {content.footer.paymentMethods} +

+
+ + Visa + + + Mastercard + + + Shop Pay + +
+
+
+
+ ); +} diff --git a/examples/tanstack-start/app/components/Header.tsx b/examples/tanstack-start/app/components/Header.tsx new file mode 100644 index 0000000000..aad6159951 --- /dev/null +++ b/examples/tanstack-start/app/components/Header.tsx @@ -0,0 +1,278 @@ +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; + +import { useCart } from "~/lib/cart"; +import { openCartDrawer, CART_DRAWER_ID } from "~/lib/cart-drawer"; +import { content, cartIconLabel, cartItemCount } from "~/lib/content"; + +import { PredictiveSearchModal } from "./PredictiveSearchModal"; + +/** Maps a header nav item to its route. "Collections" -> the collections + * index; the category items -> their collection PLP. */ +const navItemLink = { + Collections: { to: "/collections" }, + Men: { to: "/collections/$handle", params: { handle: "men" } }, + Women: { to: "/collections/$handle", params: { handle: "women" } }, + Accessories: { to: "/collections/$handle", params: { handle: "accessories" } }, +} as const; + +/** + * Site header — shared chrome (`navbar.md`, `notes/cart.md`, + * `notes/predictive-search.md`). Server-rendered nav. The cart trigger opens + * the drawer with a JS `showModal()` call; the footer `/cart` link is the no-JS + * cart fallback (F4). The search trigger is a real `/search` link that hydrates + * into the predictive-search modal. The mobile nav is a `` with an + * always-rendered fallback link list. + * + * `accountEnabled`/`isLoggedIn` are server-resolved booleans from the root + * loader (which re-runs on every route change). The account link is gated on + * `accountEnabled` first: on mock.shop the customer account handlers are not + * registered, so `/account/login` has no route and would 404 — render nothing. + */ +export function Header({ + accountEnabled, + isLoggedIn, +}: { + accountEnabled: boolean; + isLoggedIn: boolean; +}) { + const totalQuantity = useCart((state) => state.data.totalQuantity); + + const [hasHydrated, setHasHydrated] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + + useEffect(() => setHasHydrated(true), []); + + const cartLabel = cartIconLabel(totalQuantity); + const countDisplay = totalQuantity > 99 ? "99+" : String(totalQuantity); + + return ( +
+
+
+
+ +
+ + + CORE + +
+ + + +
+ {hasHydrated ? ( + + ) : ( + + + + )} + + + {accountEnabled && isLoggedIn ? ( + + + + ) : accountEnabled ? ( + + + + ) : null} + + {/* Cart trigger (hydrogen-cart-drawer). `onClick` calls showModal() + to open the `` drawer after hydration. The footer `/cart` + link remains the no-JS cart surface (F4). */} + + + {cartItemCount(totalQuantity)} + +
+
+ + setMobileNavOpen(false)} /> + + setSearchOpen(false)} /> +
+ ); +} + +function CartIcon({ count, display }: { count: number; display: string }) { + return ( + + ); +} + +function MobileNavDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + else if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + +
+
+ + {content.header.mobileNavigation} + + +
+
+ +
+
+
+ ); +} diff --git a/examples/tanstack-start/app/components/PredictiveSearchModal.tsx b/examples/tanstack-start/app/components/PredictiveSearchModal.tsx new file mode 100644 index 0000000000..f6a8e8b608 --- /dev/null +++ b/examples/tanstack-start/app/components/PredictiveSearchModal.tsx @@ -0,0 +1,209 @@ +import { + getPredictiveSearchItemUrl, + type PredictiveSearchData, + type PredictiveSearchState, +} from "@shopify/hydrogen"; +import { + PredictiveSearchProvider, + usePredictiveSearch, + usePredictiveSearchActions, + usePredictiveSearchForm, +} from "@shopify/hydrogen/react"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; + +import { content } from "~/lib/content"; +import { shopifyImageUrl } from "~/lib/image"; +import { formatPrice } from "~/lib/money"; +import { routeTemplates } from "~/lib/route-templates"; +import { searchParamsToRecord } from "~/lib/search-params"; + +const PREDICTIVE_SEARCH_LIMIT = 5; +const PREDICTIVE_SEARCH_DIALOG_ID = "search-modal"; + +/** + * Predictive search modal (`hydrogen-predictive-search` skill). Opened from the + * header search trigger. The form is a native `GET /search` fallback so a no-JS + * shopper reaches full search results (F4). The modal is a centered `` + * (`.dialog-center`). Products only, per `notes/predictive-search.md`. + */ +export function PredictiveSearchModal({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + return ( + + + + ); +} + +function PredictiveSearchDialogInner({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const dialogRef = useRef(null); + const state = usePredictiveSearch(); + const { clear } = usePredictiveSearchActions(); + const { formProps, register } = usePredictiveSearchForm(); + + // Open/close the native and clear predictive state on close so stale + // suggestions do not reappear on the next open. + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (isOpen && !dialog.open) { + dialog.showModal(); + } else if (!isOpen && dialog.open) { + dialog.close(); + clear(); + } + }, [isOpen, clear]); + + return ( + +
+
+
+ + + +
+ +
+ + {state.result.term || state.result.items.products.length > 0 ? ( +
+

+ {content.search.title} +

+ +
+ ) : ( +

+ {content.search.title} +

+ )} + + {state.result.items.products.length > 0 ? ( +
+ + View all results + +
+ ) : null} +
+
+ ); +} + +function PredictiveBody({ + state, + onNavigate, +}: { + state: PredictiveSearchState; + onNavigate: () => void; +}) { + const { status, error, result } = state; + const products = result.items.products; + const hasResults = products.length > 0; + const term = result.term; + + if (status === "loading") { + return

Searching…

; + } + if (error) { + return ( +

+ {error} +

+ ); + } + if (status === "success" && !hasResults && term) { + return ( +
+

No results for “{term}”

+
+ ); + } + if (!hasResults) return null; + + return ( +
    + {products.map((product) => { + const href = getPredictiveSearchItemUrl(product, { routes: routeTemplates, term }); + const search = searchParamsToRecord(new URL(href, "https://hydrogen.local").searchParams); + const variant = product.selectedOrFirstAvailableVariant; + const image = variant?.image ?? null; + const price = variant?.price ?? null; + return ( +
  • + + {image ? ( + {image.altText + ) : null} + + {product.title} + {price ? ( + {formatPrice(price)} + ) : null} + + +
  • + ); + })} +
+ ); +} diff --git a/examples/tanstack-start/app/components/ProductCard.tsx b/examples/tanstack-start/app/components/ProductCard.tsx new file mode 100644 index 0000000000..f0a7cae324 --- /dev/null +++ b/examples/tanstack-start/app/components/ProductCard.tsx @@ -0,0 +1,106 @@ +import type { StorefrontApi } from "@shopify/hydrogen"; +import { Link } from "@tanstack/react-router"; + +import { PRODUCT_CARD_QUERY } from "~/lib/fragments"; +import { shopifyImageUrl, srcSetFor } from "~/lib/image"; +import { formatPrice } from "~/lib/money"; + +/** The typed product card node consumed by grids across the storefront. */ +export type ProductCardData = NonNullable< + StorefrontApi.ResultOf["product"] +>; + +type ProductCardProps = { + product: ProductCardData; + /** Eager-load the first row on hero-less pages (PLP/search LCP). */ + loading?: "eager" | "lazy"; + fetchPriority?: "high" | "low" | "auto"; +}; + +/** + * Shared `ProductCard` — image with `images[1]` hover-swap, title + * (`type-body-sm line-clamp-2`), price block (`text-sale` + + * `` on sale), and `badge-sale`/`badge-soldout` + * positioned `absolute start-2 top-2`. Reused by home, collection, search, and + * the PDP "you may also like" strip (engineering.md F13). + * + * The whole card is a stretched link via `.card-link::after` so the title + * anchor's hit area climbs to the whole card. + */ +export function ProductCard({ + product, + loading = "lazy", + fetchPriority = "auto", +}: ProductCardProps) { + const primaryImage = product.featuredImage; + const secondaryImage = product.images.nodes[1] ?? null; + const minPrice = product.priceRange.minVariantPrice; + const compareAt = product.compareAtPriceRange?.minVariantPrice ?? null; + const onSale = compareAt && Number(compareAt.amount) > Number(minPrice.amount); + const soldOut = !product.availableForSale; + + return ( +
+
+ {primaryImage ? ( +
+ {primaryImage.altText +
+ ) : ( +
+ )} + {secondaryImage ? ( +
+ {secondaryImage.altText +
+ ) : null} + {onSale ? ( + + Sale + + ) : null} + {soldOut ? ( + + Sold out + + ) : null} +
+
+

+ + {product.title} + +

+
+ + {onSale ? "Sale price: " : "Price: "} + {formatPrice(minPrice)} + + {onSale && compareAt ? ( + + Regular price: + {formatPrice(compareAt)} + + ) : null} +
+
+
+ ); +} diff --git a/examples/tanstack-start/app/components/QuantityStepper.tsx b/examples/tanstack-start/app/components/QuantityStepper.tsx new file mode 100644 index 0000000000..dc99ed251b --- /dev/null +++ b/examples/tanstack-start/app/components/QuantityStepper.tsx @@ -0,0 +1,85 @@ +import { useRef } from "react"; + +/** + * Shared plus/minus quantity stepper — used by the PDP add-to-cart form and the + * cart line-item form so both surfaces share one control (per feedback: same + * picker on the product page as in the cart). + * + * Progressive enhancement: the number `` is the no-JS baseline (a real, + * editable, submittable field). The `–`/`+` buttons are `type="button"` UI + * affordances that update the input value and dispatch a native `input` event + * so any framework form binding listening on the input stays in sync. With JS + * off, the buttons do nothing and the input remains fully usable. + * + * Visual: `quantity-selector-outlined` with 44px `button-icon` touch targets + * and a compact `w-12` number field so the digits aren't cramped. + */ +type QuantityStepperProps> = { + /** Props spread onto the number `` (e.g. a form `register(...)` spread). */ + inputProps: T; + /** Accessible label for the group + controls, e.g. "Quantity: Hoodie". */ + label: string; + /** Minimum value (defaults to 1). */ + min?: number; + /** Step size (defaults to 1). */ + step?: number; + /** Class appended to the outer group. */ + className?: string; + /** Prevent changes while the owning mutation is in flight. */ + disabled?: boolean; +}; + +export function QuantityStepper>({ + inputProps, + label, + min = 1, + step = 1, + className = "", + disabled = false, +}: QuantityStepperProps) { + const inputRef = useRef(null); + + const nudge = (delta: number) => { + if (disabled) return; + const input = inputRef.current; + if (!input) return; + const next = Math.max(min, (Number(input.value) || min) + delta * step); + input.value = String(next); + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + + return ( +
+ + + +
+ ); +} diff --git a/examples/tanstack-start/app/lib/analytics.ts b/examples/tanstack-start/app/lib/analytics.ts new file mode 100644 index 0000000000..5980a4c20d --- /dev/null +++ b/examples/tanstack-start/app/lib/analytics.ts @@ -0,0 +1,45 @@ +import { + createStorefrontAnalytics, + AnalyticsEvent, + type ConsentConfig, + type ShopAnalytics, + type StorefrontAnalytics, +} from "@shopify/hydrogen"; + +export { AnalyticsEvent }; + +/** + * Analytics singleton (`hydrogen-analytics` skill). One bus per page lifetime, + * lazily created on the client. SSR no-ops via the `typeof window` guard. + * + * `shop` and `consent` are resolved on the server root from `@shared/config` + * and passed into `configureAnalytics()` before any route publishes a view + * event (F9: no polling, no init race). The whole `analyticsConsent` object + * (incl. `publicStorefrontAccessToken`) is threaded so the banner/consent mode + * matches `examples/shared/config.ts`. + */ +let bus: StorefrontAnalytics | null = null; +let analyticsShop: ShopAnalytics | null = null; +let analyticsConsent: ConsentConfig | null = null; + +export function configureAnalytics(shop: ShopAnalytics, consent: ConsentConfig) { + if (typeof window === "undefined") return; + analyticsShop = shop; + analyticsConsent = consent; +} + +export function getAnalyticsShop(): ShopAnalytics | null { + return analyticsShop; +} + +export function getAnalytics(): StorefrontAnalytics | null { + if (typeof window === "undefined") return null; // SSR no-op + if (!analyticsShop || !analyticsConsent) return null; + if (bus) return bus; + bus = createStorefrontAnalytics({ + shop: analyticsShop, + consent: analyticsConsent, + // canTrack: leave the default (Customer Privacy API) in production. + }); + return bus; +} diff --git a/examples/tanstack-start/app/lib/cart-drawer.ts b/examples/tanstack-start/app/lib/cart-drawer.ts new file mode 100644 index 0000000000..e90278b272 --- /dev/null +++ b/examples/tanstack-start/app/lib/cart-drawer.ts @@ -0,0 +1,60 @@ +export const CART_DRAWER_ID = "cart-drawer"; +const STANDARD_ACTIONS_READY_EVENT = "DOMContentLoaded"; + +let openCartActionConfigured = false; +let openCartActionRetryQueued = false; + +function getCartDrawer() { + if (typeof document === "undefined") return null; + + const drawer = document.getElementById(CART_DRAWER_ID); + return drawer instanceof HTMLDialogElement ? drawer : null; +} + +/** Open the cart drawer (`` + `showModal()`). */ +export function openCartDrawer() { + const drawer = getCartDrawer(); + if (!drawer || drawer.open) return; + drawer.showModal(); +} + +/** Close the cart drawer. */ +export function closeCartDrawer() { + getCartDrawer()?.close(); +} + +function configureOpenCartActionNow() { + const openCart = typeof window !== "undefined" ? window.Shopify?.actions?.openCart : undefined; + if (!openCart) return false; + + openCart.configure({ + handler: async () => openCartDrawer(), + }); + openCartActionConfigured = true; + return true; +} + +/** + * Register the drawer's DOM helper as the `window.Shopify.actions.openCart()` + * Standard Action handler (`hydrogen-cart-drawer` skill). The module-scope call + * no-ops during SSR, configures immediately when Standard Actions is available, + * and retries once on `DOMContentLoaded` when the runtime loads after this + * module. + */ +export function configureOpenCartAction() { + if (typeof document === "undefined" || openCartActionConfigured) return; + if (configureOpenCartActionNow()) return; + if (openCartActionRetryQueued || document.readyState !== "loading") return; + + openCartActionRetryQueued = true; + document.addEventListener( + STANDARD_ACTIONS_READY_EVENT, + () => { + openCartActionRetryQueued = false; + configureOpenCartAction(); + }, + { once: true }, + ); +} + +configureOpenCartAction(); diff --git a/examples/tanstack-start/app/lib/cart-handlers.ts b/examples/tanstack-start/app/lib/cart-handlers.ts new file mode 100644 index 0000000000..89ebc533e6 --- /dev/null +++ b/examples/tanstack-start/app/lib/cart-handlers.ts @@ -0,0 +1,43 @@ +import { createCartServerHandlers, gql } from "@shopify/hydrogen"; + +/** + * Custom cart fragment — adds `updatedAt` (for analytics cart-change dedupe) + * and `merchandise.price`/`product.id`/`product.vendor` (required by the + * `AnalyticsCart` shape the analytics bus consumes). Composed alongside the + * built-in `HydrogenCartFragment` by `createCartServerHandlers`. + * + * `hydrogen-analytics`: `updateCart()` keys dedupe on `updatedAt`; without it + * cart events are silently ignored. The bus's `AnalyticsCartLine` requires + * `merchandise.price` and `product.{id,vendor}`, which the default fragment + * omits. + */ +const cartFragment = gql(` + fragment CartFragment on Cart { + updatedAt + lines(first: 250) { + nodes { + merchandise { + ... on ProductVariant { + price { + amount + currencyCode + } + } + } + } + } + } +`); + +/** + * Cart server handlers, registered in the root middleware's + * `handleShopifyRoutes` wiring. The React cart bindings in `app/lib/cart.ts` + * are derived from these handlers' type so the cart provider's `initialData` + * envelope and line types stay in sync with the server contract. + * + * `hydrogen-request-handlers` / `references/frameworks.md` owns the wiring; the + * `hydrogen-cart-ui` React reference owns the provider/form helpers. + */ +export const cartHandlers = createCartServerHandlers({ + fragment: cartFragment, +}); diff --git a/examples/tanstack-start/app/lib/cart.ts b/examples/tanstack-start/app/lib/cart.ts new file mode 100644 index 0000000000..4502dbd51a --- /dev/null +++ b/examples/tanstack-start/app/lib/cart.ts @@ -0,0 +1,11 @@ +import { createCartComponents } from "@shopify/hydrogen/react"; + +import type { cartHandlers } from "./cart-handlers"; + +/** + * React cart bindings derived from the cart server handlers' type. The + * `CartProvider` accepts the full handler data envelope (`{cart, errors?}`) as + * `initialData` — see `hydrogen-cart-ui` / `references/react.md`. Do not unwrap + * to `data.cart`: `{cart: null}` tells the client the server already checked. + */ +export const { CartProvider, useCart, useCartForm } = createCartComponents(); diff --git a/examples/tanstack-start/app/lib/content.ts b/examples/tanstack-start/app/lib/content.ts new file mode 100644 index 0000000000..545d009f6d --- /dev/null +++ b/examples/tanstack-start/app/lib/content.ts @@ -0,0 +1,148 @@ +/** + * Copy strings transcribed from `examples/core/content.json`. Centralized so + * the TanStack Start example uses the same verified copy as the other examples. + */ +export const content = { + announcement: { + label: "Announcement", + text: "Free shipping on orders over $50", + }, + general: { + skipToContent: "Skip to content", + search: "Search", + account: "Account", + back: "Back", + close: "Close", + drawer: "Drawer", + dismiss: "Dismiss", + }, + header: { + menu: "Menu", + navigation: "Main navigation", + mobileNavigation: "Mobile navigation", + navItems: ["Collections", "Men", "Women", "Accessories"] as const, + }, + footer: { + quickLinks: "Quick links", + customerCare: "Customer care", + search: "Search", + account: "Account", + paymentMethods: "Payment methods", + }, + cart: { + title: "Cart", + checkout: "Checkout", + empty: "Your cart is empty.", + emptyDescription: "Looks like you haven't added anything to your cart yet.", + totalLabel: "Estimated total", + taxesAndShippingAtCheckout: "Taxes and shipping calculated at checkout", + itemRemoved: "Item removed from cart", + updated: "Cart updated", + updateError: "Could not update cart. Please try again.", + iconLabel: { + one: "Cart (1 item)", + other: "Cart ({{ count }} items)", + }, + itemCount: { + one: "1 item in cart", + other: "{{ count }} items in cart", + }, + }, + product: { + details: "Product details", + quantity: "Quantity", + addToCart: "Add to cart", + selectOptions: "Select options", + addedToCart: "Added to cart", + soldOut: "Out of stock", + description: "Description", + relatedProducts: "You may also like", + badge: { + soldOut: "Sold out", + sale: "Sale", + }, + inventory: { + inStock: "In stock", + lowStock: "Low stock", + lowStockCount: "Only {{ count }} left in stock", + outOfStock: "Out of stock", + }, + }, + collection: { + title: "Outerwear", + description: + "Layering pieces built for shifting weather — midweight overshirts, field jackets, and knitwear cut from durable natural fibers.", + productsCount: { + one: "1 product", + other: "{{ count }} products", + }, + sortBy: "Sort by", + filters: "Filters", + filter: "Filter", + showResults: "Show results", + clearAll: "Clear all", + activeFilters: "Active filters", + removeFilter: "Remove {{ filter }} filter", + priceMin: "Min", + priceMax: "Max", + priceTo: "to", + loadMore: "Load more", + showingCount: "Showing {{ shown }} of {{ total }} products", + noProducts: "No products found.", + }, + collections: { + title: "Collections", + allCollections: "All collections", + productCount: { + one: "1 product", + other: "{{ count }} products", + }, + viewCollection: "View {{ title }}", + }, + search: { + title: "Search", + placeholder: "Search", + label: "Search", + submit: "Search", + clear: "Clear search", + resultsFor: "{{ count }} results found for {{ terms }}", + showingCount: "Showing {{ shown }} of {{ total }} results", + loadMore: "Load more", + noResults: "No results found for {{ terms }}", + noResultsSuggestion: "Check your spelling or try a more general term.", + noResultsAnnouncement: "No results found for {{ terms }}", + }, + home: { + hero: { + heading: "Discover our latest collection", + subtitle: "Explore our curated selection of premium products", + primaryCta: "Shop now", + secondaryCta: "Learn more", + }, + bestSellers: "Best sellers", + shopByCategory: "Shop by category", + viewAll: "View all", + }, + consent: { + label: "Cookie consent", + message: "We use cookies to improve your experience, analyze traffic, and personalize content.", + privacyPolicy: "Privacy Policy", + acceptAll: "Accept all", + decline: "Decline", + managePreferences: "Manage preferences", + }, +} as const; + +/** Pluralized cart icon label. */ +export function cartIconLabel(count: number): string { + return count === 1 + ? content.cart.iconLabel.one + : content.cart.iconLabel.other.replace("{{ count }}", String(count)); +} + +/** Pluralized cart item-count live-region text. */ +export function cartItemCount(count: number): string { + return count === 1 + ? content.cart.itemCount.one + : content.cart.itemCount.other.replace("{{ count }}", String(count)); +} diff --git a/examples/tanstack-start/app/lib/customer-account.ts b/examples/tanstack-start/app/lib/customer-account.ts new file mode 100644 index 0000000000..c97da6febb --- /dev/null +++ b/examples/tanstack-start/app/lib/customer-account.ts @@ -0,0 +1,46 @@ +import { customerAccountConfig } from "@shared/config"; +import { EncryptedCookieCustomerSession } from "@shared/customer-session"; +import type { ShopifyRequestContext } from "@shopify/hydrogen"; +import { + createCustomerSession, + type CustomerAccountClient, + type CustomerSession, + type WritableCustomerSessionManager, +} from "@shopify/hydrogen/customer-account"; + +/** + * Per-request Customer Account context (NOT React component context) passed + * from the server entry into TanStack middleware, server functions, and route + * handlers. + * + * `available` is false when the storefront is running against mock.shop (no + * `PRIVATE_STOREFRONT_API_TOKEN`); in that case the customer account handlers + * are not registered and loaders should short-circuit with a "requires a real + * store" notice. `client` is still created — it is cheap (no network) and lets + * the context shape stay uniform across both branches. + */ +export type CustomerAccountRequestContext = { + available: boolean; + client: CustomerAccountClient; + requestContext: ShopifyRequestContext; + session: CustomerSession; + sessionManager: WritableCustomerSessionManager; +}; + +// Singleton — the customer session owns OAuth endpoints and the in-flight +// refresh dedupe map, both of which are stable for the app's lifetime. +export const customerSession = createCustomerSession({ + shopId: customerAccountConfig.shopId, + customerAccountApiClientId: customerAccountConfig.customerAccountApiClientId, +}); + +/** + * Build a per-request, encrypted-cookie-backed session manager. Replaces the + * in-memory `createRequestSessionManager` (which had no `commit()` and could + * not persist tokens across the OAuth login → callback redirect). Cart and + * predictive-search handlers never touch the session manager, so the swap is + * safe. + */ +export async function createCustomerSessionManager(request: Request) { + return EncryptedCookieCustomerSession.init(request, customerAccountConfig.sessionSecret); +} diff --git a/examples/tanstack-start/app/lib/customer-session-handlers.ts b/examples/tanstack-start/app/lib/customer-session-handlers.ts new file mode 100644 index 0000000000..1ca4bc30bb --- /dev/null +++ b/examples/tanstack-start/app/lib/customer-session-handlers.ts @@ -0,0 +1,16 @@ +import { createCustomerAccountServerHandlers } from "@shopify/hydrogen/customer-account"; + +import { customerSession } from "./customer-account"; + +/** + * Customer Account server handlers, registered (conditionally) in the root + * middleware's `handleShopifyRoutes` wiring. Produces handlers for + * `/account/login` (GET), `/account/logout` (POST), `/account/refresh` (GET), + * and `/account/authorize` (GET) — all intercepted before framework routing, + * so no route files are needed. + */ +export const customerSessionHandlers = createCustomerAccountServerHandlers({ + customerSession, + defaultPostLoginRedirectPathname: "/account", + postLogoutRedirectUri: "/", +}); diff --git a/examples/tanstack-start/app/lib/filters.tsx b/examples/tanstack-start/app/lib/filters.tsx new file mode 100644 index 0000000000..769fe2fb44 --- /dev/null +++ b/examples/tanstack-start/app/lib/filters.tsx @@ -0,0 +1,280 @@ +import { + getFilterRemovalUrl, + isFilterInputActive, + serializeCollectionParams, + type AvailableFilter, + type ProductFilter, +} from "@shopify/hydrogen"; +import { Link } from "@tanstack/react-router"; + +import { content } from "./content"; +import { searchParamsToRecord } from "./search-params"; + +/** + * Shared collection/search browse helpers (`hydrogen-collection-browser`). + * Extracted so the collection PLP and the search page render filters + * identically and don't fork the param-serialization + value-input logic. + */ + +/** Remove all filter values and the stale cursor while preserving other URL state. */ +export function clearFilterParams(searchParams: URLSearchParams): URLSearchParams { + const cleared = new URLSearchParams(searchParams); + for (const key of Array.from(cleared.keys())) { + if (key.startsWith("filter.")) cleared.delete(key); + } + cleared.delete("after"); + return cleared; +} + +/** Serialize a Storefront API filter `input` string into form field entries. */ +export function filterValueInputParamEntries( + input: string, +): Array<{ name: string; value: string }> { + let parsedFilter: ProductFilter; + try { + // F13: skill-sanctioned cast mirroring hydrogen-collection-browser/references/react.md + // (JSON.parse of the Storefront `FilterValue.input` JSON string). + parsedFilter = JSON.parse(input) as ProductFilter; + } catch { + return []; + } + + return Array.from( + serializeCollectionParams({ + filters: [parsedFilter], + sortKey: undefined, + reverse: false, + }), + ([name, value]) => ({ name, value }), + ); +} + +/** Active price filter values (for prefilling min/max), if any. */ +export function activePriceRange(activeFilters: ProductFilter[]): { min: string; max: string } { + const price = activeFilters.find((f) => f.price)?.price; + return { + min: price?.min != null ? String(price.min) : "", + max: price?.max != null ? String(price.max) : "", + }; +} + +/** A single checkbox filter value (LIST / BOOLEAN filter types). */ +export function FilterValueInput({ + filter: _filter, + value, + activeFilters, +}: { + filter: AvailableFilter; + value: { id: string; label: string; count: number; input: string }; + activeFilters: ProductFilter[]; +}) { + const entries = filterValueInputParamEntries(value.input); + if (entries.length !== 1) return null; + + const [{ name, value: paramValue }] = entries; + + return ( + + ); +} + +/** A min/max price range filter (PRICE_RANGE filter type). */ +export function PriceRangeFilter({ + filter, + activeFilters, +}: { + filter: AvailableFilter; + activeFilters: ProductFilter[]; +}) { + const { min, max } = activePriceRange(activeFilters); + return ( +
+ {filter.label} +
+ + {content.collection.priceTo} + +
+
+ ); +} + +/** A filter group: renders a PRICE_RANGE or a list of checkbox values. */ +export function FilterGroup({ + filter, + activeFilters, +}: { + filter: AvailableFilter; + activeFilters: ProductFilter[]; +}) { + if (filter.type === "PRICE_RANGE") { + return ; + } + return ( +
+ {filter.label} + {filter.values.map((value) => ( + + ))} +
+ ); +} + +/** One set of facet controls: collapsible on mobile and persistently visible on desktop. */ +export function FilterPanel({ + availableFilters, + activeFilters, +}: { + availableFilters: AvailableFilter[]; + activeFilters: ProductFilter[]; +}) { + if (availableFilters.length === 0) return null; + + return ( + + ); +} + +/** Progressive-enhancement links for removing one filter or clearing all filters. */ +export function ActiveFilterChips({ + activeFilters, + pathname, + searchParams, + clearSearchParams, +}: { + activeFilters: ProductFilter[]; + pathname: string; + searchParams: URLSearchParams; + clearSearchParams: URLSearchParams; +}) { + if (activeFilters.length === 0) return null; + + const removalBase = new URLSearchParams(searchParams); + removalBase.delete("after"); + + return ( +
    + {activeFilters.map((filter, index) => { + const label = describeFilter(filter); + const removal = getFilterRemovalUrl(removalBase, filter); + const removalParams = new URLSearchParams(removal === "?" ? "" : removal); + return ( +
  • + + {label} + + +
  • + ); + })} +
  • + + {content.collection.clearAll} + +
  • +
+ ); +} + +export function describeFilter(filter: ProductFilter): string { + if (filter.available !== undefined) 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) { + const option = filter.variantOption; + return option.value ?? option.name ?? "Variant"; + } + if (filter.price) { + const price = filter.price; + const hasMin = price.min != null && Number(price.min) > 0; + const hasMax = price.max != null; + if (hasMin && hasMax) return `${price.min} ${content.collection.priceTo} ${price.max}`; + if (hasMax) return `Up to ${price.max}`; + if (hasMin) return `From ${price.min}`; + return "Price"; + } + return "Filter"; +} diff --git a/examples/tanstack-start/app/lib/fragments.ts b/examples/tanstack-start/app/lib/fragments.ts new file mode 100644 index 0000000000..2da2f9fe0b --- /dev/null +++ b/examples/tanstack-start/app/lib/fragments.ts @@ -0,0 +1,125 @@ +import { gql } from "@shopify/hydrogen"; + +/** + * Shared `ProductCard` fragment — reused by the home best-sellers grid, the + * collection/search grids, and the product page's "you may also like" strip + * (engineering.md F13, F5). Title-only cards; no per-card fan-out. + */ +export const PRODUCT_CARD_FRAGMENT = gql(` + fragment ProductCard on Product { + id + handle + title + vendor + availableForSale + featuredImage { + url + altText + width + height + } + images(first: 2) { + nodes { + url + altText + } + } + priceRange { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + compareAtPriceRange { + minVariantPrice { + amount + currencyCode + } + } + } +`); + +/** + * Thin query that spreads the `ProductCard` fragment so the card data type can + * be derived via `StorefrontApi.ResultOf` (fragments alone resolve to `never`). + */ +export const PRODUCT_CARD_QUERY = gql( + `query ProductCardQuery { product(handle: "") { ...ProductCard } }`, + [PRODUCT_CARD_FRAGMENT], +); + +/** + * Shared collection-card fragment — used by the collections index and the home + * "shop by category" grid. Pulls a single product image as a fallback when the + * collection has no image (F5: no large fan-out for a cosmetic count). + */ +export const COLLECTION_CARD_FRAGMENT = gql(` + fragment CollectionCard on Collection { + id + handle + title + description + image { + url + altText + width + height + } + products(first: 1) { + nodes { + featuredImage { + url + altText + } + } + } + } +`); + +/** Thin query that spreads the `CollectionCard` fragment for type derivation. */ +export const COLLECTION_CARD_QUERY = gql( + `query CollectionCardQuery { collection(handle: "") { ...CollectionCard } }`, + [COLLECTION_CARD_FRAGMENT], +); + +/** + * Variant fields fragment — one reusable shape for + * `firstSelectableVariant`, `selectedOrFirstAvailableVariant`, and + * `adjacentVariants` (`hydrogen-setup` / `references/product-page.md`). After + * option selection, `selectedVariant` can come from any of those caches and + * must still contain the fields the UI needs. + */ +export const VARIANT_FIELDS_FRAGMENT = gql(` + fragment VariantFields on ProductVariant { + id + title + availableForSale + selectedOptions { + name + value + } + price { + amount + currencyCode + } + compareAtPrice { + amount + currencyCode + } + image { + url + altText + width + height + } + product { + title + handle + } + sku + } +`); diff --git a/examples/tanstack-start/app/lib/image.ts b/examples/tanstack-start/app/lib/image.ts new file mode 100644 index 0000000000..d7506ffa1c --- /dev/null +++ b/examples/tanstack-start/app/lib/image.ts @@ -0,0 +1,61 @@ +/** + * Shopify CDN image sizing helper (`hydrogen-image` skill). + * + * Hydrogen ships no Image component. Size Shopify CDN image URLs with this tiny + * helper and render plain ``. Append CDN sizing params with + * `URL.searchParams` (never string-concat) so an existing query string is + * preserved. Only rewrite Shopify CDN hosts; pass third-party images (e.g. an + * Unsplash hero) through unchanged. + * + * CDN params: https://shopify.dev/docs/api/storefront/latest/input-objects/ImageTransformInput + */ + +type ShopifyImageOptions = { + width?: number; + height?: number; + crop?: "center" | "top" | "bottom" | "left" | "right"; +}; + +/** Shopify CDN hosts (and their subdomains) that may be rewritten. */ +const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "mock.shop"]; + +function isShopifyImageHost(hostname: string): boolean { + return SHOPIFY_CDN_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`)); +} + +/** + * Append Shopify CDN sizing params to `url`. Non-Shopify hosts and unparseable + * URLs are returned unchanged. Existing query params are preserved. + */ +export function shopifyImageUrl(url: string, options: ShopifyImageOptions = {}): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + + if (!isShopifyImageHost(parsed.hostname)) return url; + + if (options.width) parsed.searchParams.set("width", String(options.width)); + if (options.height) parsed.searchParams.set("height", String(options.height)); + if (options.crop) parsed.searchParams.set("crop", options.crop); + + return parsed.toString(); +} + +/** + * Build a 1x/2x DPR `srcset` for a Shopify CDN image. Each descriptor is sized + * via `shopifyImageUrl`. For width-descriptor srcsets (e.g. the home hero), use + * a custom srcset string instead — `sizes` is a no-op for DPR descriptors. + */ +export function srcSetFor(url: string, options: ShopifyImageOptions): string { + const oneX = shopifyImageUrl(url, options); + const twoX = shopifyImageUrl(url, { + ...options, + width: options.width ? options.width * 2 : undefined, + height: options.height ? options.height * 2 : undefined, + }); + + return `${oneX} 1x, ${twoX} 2x`; +} diff --git a/examples/tanstack-start/app/lib/money.ts b/examples/tanstack-start/app/lib/money.ts new file mode 100644 index 0000000000..1e4b5b4d04 --- /dev/null +++ b/examples/tanstack-start/app/lib/money.ts @@ -0,0 +1,18 @@ +import { formatMoney, type MoneyV2 } from "@shopify/hydrogen"; + +/** + * App wrapper around Hydrogen's `formatMoney()` (`hydrogen-money` skill). + * Keeps locale and display options consistent. Never build money strings by + * concatenation and never compute totals client-side. + * + * This storefront is single-market (US/EN), so `en-US` is the correct locale. + * Market-aware stores would pass the active market locale instead. + */ +export function formatPrice(money: MoneyV2, locale = "en-US"): string { + return formatMoney(money, { locale }).toString(); +} + +/** Format a price range from min/max `MoneyV2` values. */ +export function formatPriceRange(min: MoneyV2, max: MoneyV2, locale = "en-US"): string { + return formatMoney([min, max], { locale }).toString(); +} diff --git a/examples/tanstack-start/app/lib/predictive-search-handlers.ts b/examples/tanstack-start/app/lib/predictive-search-handlers.ts new file mode 100644 index 0000000000..0ebfcf675a --- /dev/null +++ b/examples/tanstack-start/app/lib/predictive-search-handlers.ts @@ -0,0 +1,11 @@ +import { createPredictiveSearchServerHandlers } from "@shopify/hydrogen"; + +/** + * Predictive search server handlers, registered in the root middleware's + * `handleShopifyRoutes` wiring. The browser autocomplete endpoint is + * `/api/predictive-search` by default. Limited to products per + * `notes/predictive-search.md`. + */ +export const predictiveSearchHandlers = createPredictiveSearchServerHandlers({ + types: ["PRODUCT"], +}); diff --git a/examples/tanstack-start/app/lib/product-query.ts b/examples/tanstack-start/app/lib/product-query.ts new file mode 100644 index 0000000000..d1a67940eb --- /dev/null +++ b/examples/tanstack-start/app/lib/product-query.ts @@ -0,0 +1,111 @@ +import { gql, type StorefrontApi } from "@shopify/hydrogen"; + +import { PRODUCT_CARD_FRAGMENT, VARIANT_FIELDS_FRAGMENT } from "./fragments"; + +/** + * Product detail query (`hydrogen-setup` / `references/product-page.md` + + * `hydrogen-variant-form`). Derives URL-selected options with + * `getSelectedProductOptions`, includes the variant-form encoded fields, and + * uses one reusable `VariantFields` fragment across all variant caches. + */ +export const PRODUCT_QUERY = gql( + ` + query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + product(handle: $handle) { + id + handle + title + vendor + description + descriptionHtml + requiresSellingPlan + options { + name + optionValues { + name + firstSelectableVariant { + ...VariantFields + } + swatch { + color + image { + ... on MediaImage { + image { + url + altText + } + } + } + } + } + } + encodedVariantExistence + encodedVariantAvailability + selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + ...VariantFields + } + adjacentVariants(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + ...VariantFields + } + media(first: 8) { + nodes { + __typename + id + mediaContentType + alt + ... on MediaImage { + image { + url + altText + width + height + } + } + previewImage { + url + altText + width + height + } + } + } + priceRange { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + } + } +`, + [VARIANT_FIELDS_FRAGMENT], +); + +/** Related products query for the "you may also like" strip. */ +export const RELATED_PRODUCTS_QUERY = gql( + ` + query RelatedProducts($handle: String!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + product(handle: $handle) { + relatedProducts: collections(first: 1) { + nodes { + products(first: 5) { + nodes { + ...ProductCard + } + } + } + } + } + } +`, + [PRODUCT_CARD_FRAGMENT], +); + +/** The typed product data consumed by the React product bindings. */ +export type ProductData = NonNullable["product"]>; diff --git a/examples/tanstack-start/app/lib/product.ts b/examples/tanstack-start/app/lib/product.ts new file mode 100644 index 0000000000..2924f6eb75 --- /dev/null +++ b/examples/tanstack-start/app/lib/product.ts @@ -0,0 +1,10 @@ +import { createProductComponents } from "@shopify/hydrogen/react"; + +import type { ProductData } from "./product-query"; + +/** + * React product bindings derived from the typed product query + * (`hydrogen-variant-form` / `references/react.md`). The provider owns variant + * selection state; `onSelect` is where same-product URL sync happens. + */ +export const { ProductProvider, useProductForm } = createProductComponents(); diff --git a/examples/tanstack-start/app/lib/route-templates.ts b/examples/tanstack-start/app/lib/route-templates.ts new file mode 100644 index 0000000000..4d2dfdbc3e --- /dev/null +++ b/examples/tanstack-start/app/lib/route-templates.ts @@ -0,0 +1,8 @@ +import { createShopifyRouteTemplates } from "@shopify/hydrogen"; + +// Canonicalize Shopify's collection-scoped product route to the product page +// this example handles. Add entries here if this example adopts custom +// product, collection, page, blog, or article paths. +export const routeTemplates = createShopifyRouteTemplates({ + productInCollection: "/products/:productHandle", +}); diff --git a/examples/tanstack-start/app/lib/search-params.ts b/examples/tanstack-start/app/lib/search-params.ts new file mode 100644 index 0000000000..ea2cb7eeac --- /dev/null +++ b/examples/tanstack-start/app/lib/search-params.ts @@ -0,0 +1,19 @@ +/** Convert URLSearchParams to TanStack Router's string/repeated-string search shape. */ +export function searchParamsToRecord( + searchParams: URLSearchParams, +): Record { + const search = Object.create(null) as Record; + + searchParams.forEach((value, key) => { + const current = search[key]; + if (current === undefined) { + search[key] = value; + } else if (Array.isArray(current)) { + current.push(value); + } else { + search[key] = [current, value]; + } + }); + + return search; +} diff --git a/examples/tanstack-start/app/lib/site.ts b/examples/tanstack-start/app/lib/site.ts new file mode 100644 index 0000000000..b3c6dc5ea4 --- /dev/null +++ b/examples/tanstack-start/app/lib/site.ts @@ -0,0 +1,27 @@ +/** + * Trusted site origin for SEO (engineering.md F6/F10). Comes from a + * `PUBLIC_SITE_ORIGIN` environment variable, never from attacker-influenceable + * `host` / `x-forwarded-host` request headers. Defaults to the local dev origin + * so the example works without extra config. + */ +export const SITE_ORIGIN = + typeof process !== "undefined" + ? (process.env.PUBLIC_SITE_ORIGIN ?? "http://localhost:5173") + : "http://localhost:5173"; + +/** Build an absolute canonical URL from a path. */ +export function canonicalUrl(path: string): string { + return new URL(path, SITE_ORIGIN).toString(); +} + +/** + * Serialize JSON-LD and escape it for safe embedding in a `` so the payload cannot break out of the + * script element. + */ +export function jsonLdScript(data: object): string { + const json = JSON.stringify(data); + const escaped = json.replace(//gi, "\\u003c/script\\u003e"); + return escaped; +} diff --git a/examples/tanstack-start/app/lib/storefront-cache.ts b/examples/tanstack-start/app/lib/storefront-cache.ts new file mode 100644 index 0000000000..4ff211e5d4 --- /dev/null +++ b/examples/tanstack-start/app/lib/storefront-cache.ts @@ -0,0 +1,19 @@ +import { + STOREFRONT_CACHE_MAX_ENTRIES, + createStorefrontCacheAdapter, +} from "@shared/storefront-cache"; +import { LRUCache } from "lru-cache"; + +/** + * Module-level LRU cache for non-personalized catalog reads + * (engineering.md F2). Lives for the duration of the server process and is + * shared across requests for cacheable Storefront queries. Personalized reads + * (cart, buyer-context state) never flow through this cache — they use the + * request-scoped client with `Cache.none()`. + */ +const storefrontLruCache = new LRUCache({ + max: STOREFRONT_CACHE_MAX_ENTRIES, + // ttl is set per-entry by the adapter from the cache strategy's maxAge. +}); + +export const storefrontCache = createStorefrontCacheAdapter(storefrontLruCache); diff --git a/examples/tanstack-start/app/lib/storefront-context.server.ts b/examples/tanstack-start/app/lib/storefront-context.server.ts new file mode 100644 index 0000000000..19a3fffc07 --- /dev/null +++ b/examples/tanstack-start/app/lib/storefront-context.server.ts @@ -0,0 +1,120 @@ +import { BUYER_IP_HEADERS, DEVELOPMENT_BUYER_IP, getBuyerIp } from "@shared/buyer-ip"; +import { customerAccountConfig, defaultI18n, storefrontConfig } from "@shared/config"; +import { getOptionalSharedSecret } from "@shared/private-env"; +import { createShopifyRequestContext, createStorefrontClient, getCartId } from "@shopify/hydrogen"; +import { createCustomerAccountClient } from "@shopify/hydrogen/customer-account"; + +import { cartHandlers } from "./cart-handlers"; +import { createCustomerSessionManager, customerSession } from "./customer-account"; +import { customerSessionHandlers } from "./customer-session-handlers"; +import { predictiveSearchHandlers } from "./predictive-search-handlers"; +import { storefrontCache } from "./storefront-cache"; +import type { HydrogenRequestContext } from "./storefront-context"; + +const MOCK_SHOP_DOMAIN = "mock.shop"; +const MOCK_SHOP_PRIVATE_TOKEN = "mock-private-token"; + +/** + * Create every Hydrogen capability once per request. This module is only + * imported by the server entry so secrets and route-handler queries never + * enter TanStack Start's client dependency graph. + */ +export async function createHydrogenRequestContext( + request: Request, +): Promise { + const shopifyRequestContext = createShopifyRequestContext({ + request, + i18n: defaultI18n, + }); + const sessionManager = await createCustomerSessionManager(request); + const privateStorefrontToken = getOptionalSharedSecret("PRIVATE_STOREFRONT_API_TOKEN"); + const usingMockShop = !privateStorefrontToken; + const storeDomain = usingMockShop + ? MOCK_SHOP_DOMAIN + : (process.env.PUBLIC_STORE_DOMAIN ?? storefrontConfig.storeDomain); + + // The root loader exposes cart state (including checkout URLs) on every page, + // and /api/cart always returns buyer-specific state. Mark either request as + // personalized before TanStack renders or a route handler returns so the + // final response cannot be cached publicly. + if (getCartId(request) || new URL(request.url).pathname === "/api/cart") { + shopifyRequestContext.markResponseAsPersonalized("cart"); + } + + if (usingMockShop && !mockShopFallbackWarned) { + mockShopFallbackWarned = true; + console.warn( + "[hydrogen-example-tanstack-start] No PRIVATE_STOREFRONT_API_TOKEN found — " + + `running against ${MOCK_SHOP_DOMAIN}.`, + ); + } + + const storefrontClient = createStorefrontClient({ + type: "private", + requestContext: shopifyRequestContext, + config: { + storeDomain, + privateStorefrontToken: privateStorefrontToken ?? MOCK_SHOP_PRIVATE_TOKEN, + buyerIp: resolveBuyerIp(request.headers, usingMockShop), + cache: storefrontCache, + }, + }); + const customerAccountsAvailable = !usingMockShop; + const customerAccountClient = createCustomerAccountClient({ + shopId: customerAccountConfig.shopId, + requestContext: shopifyRequestContext, + }); + + return { + request, + shopifyRequestContext, + storefrontClient, + customerAccount: { + available: customerAccountsAvailable, + client: customerAccountClient, + requestContext: shopifyRequestContext, + session: customerSession, + sessionManager, + }, + cartHandlers, + shopifyRouteHandlers: [ + cartHandlers, + predictiveSearchHandlers, + ...(customerAccountsAvailable ? [customerSessionHandlers] : []), + ], + }; +} + +/** + * Production runtimes normally provide a trusted buyer-IP header. Local + * production builds do not, so mock.shop uses the same deterministic fallback + * as development while real private Storefront clients remain strict. + */ +export function resolveBuyerIp(headers: Pick, usingMockShop: boolean): string { + if (!usingMockShop) return getBuyerIp(headers); + + for (const header of BUYER_IP_HEADERS) { + const buyerIp = headers.get(header)?.split(",")[0]?.trim(); + if (buyerIp) return buyerIp; + } + + return DEVELOPMENT_BUYER_IP; +} + +/** Commit request-scoped session and Storefront response metadata exactly once. */ +export async function finalizeHydrogenResponse( + response: Response, + context: HydrogenRequestContext, +): Promise { + const mutableResponse = new Response(response.body, response); + const sessionHeaders = await context.customerAccount.sessionManager.commit?.(); + if (sessionHeaders) appendHeaders(sessionHeaders, mutableResponse.headers); + context.shopifyRequestContext.applyResponseHeaders(mutableResponse.headers); + return mutableResponse; +} + +function appendHeaders(source: HeadersInit, target: Headers): void { + new Headers(source).forEach((value, key) => target.append(key, value)); +} + +let mockShopFallbackWarned = false; diff --git a/examples/tanstack-start/app/lib/storefront-context.ts b/examples/tanstack-start/app/lib/storefront-context.ts new file mode 100644 index 0000000000..ee778e8099 --- /dev/null +++ b/examples/tanstack-start/app/lib/storefront-context.ts @@ -0,0 +1,36 @@ +import type { + CachingStrategy, + RequestScopedPrivateStorefrontClient, + ShopifyRouteHandlerGroup, + ShopifyRequestContext, +} from "@shopify/hydrogen"; + +import type { cartHandlers } from "./cart-handlers"; +import type { CustomerAccountRequestContext } from "./customer-account"; + +export type HydrogenRequestContext = { + request: Request; + shopifyRequestContext: ShopifyRequestContext; + storefrontClient: RequestScopedPrivateStorefrontClient<{ + cache?: CachingStrategy; + }>; + customerAccount: CustomerAccountRequestContext; + cartHandlers: typeof cartHandlers; + shopifyRouteHandlers: readonly ShopifyRouteHandlerGroup[]; +}; + +declare module "@tanstack/react-router" { + interface Register { + server: { + requestContext: HydrogenRequestContext; + }; + } +} + +declare module "@tanstack/react-start" { + interface Register { + server: { + requestContext: HydrogenRequestContext; + }; + } +} diff --git a/examples/tanstack-start/app/lib/storefront-middleware.ts b/examples/tanstack-start/app/lib/storefront-middleware.ts new file mode 100644 index 0000000000..6adebfa922 --- /dev/null +++ b/examples/tanstack-start/app/lib/storefront-middleware.ts @@ -0,0 +1,29 @@ +import { handleShopifyRedirects, handleShopifyRoutes } from "@shopify/hydrogen"; +import { createMiddleware } from "@tanstack/react-start"; + +import { routeTemplates } from "./route-templates"; + +/** Run Hydrogen's framework-neutral standard routes before the file router. */ +export const storefrontMiddleware = createMiddleware().server( + async ({ request, context, handlerType, next }) => { + const shopifyResponse = await handleShopifyRoutes({ + request, + requestContext: context.shopifyRequestContext, + sessionManager: context.customerAccount.sessionManager, + storefrontClient: context.storefrontClient, + handlers: context.shopifyRouteHandlers, + }); + if (shopifyResponse) return shopifyResponse; + + const result = await next(); + if (handlerType !== "router" || result.response.status !== 404) return result; + + return ( + (await handleShopifyRedirects({ + request, + storefrontClient: context.storefrontClient, + routeTemplates, + })) ?? result + ); + }, +); diff --git a/examples/tanstack-start/app/root.tsx b/examples/tanstack-start/app/root.tsx new file mode 100644 index 0000000000..377104642b --- /dev/null +++ b/examples/tanstack-start/app/root.tsx @@ -0,0 +1,222 @@ +import { + analyticsConsent as analyticsConsentConfig, + analyticsShop as analyticsShopConfig, + defaultI18n, + shop, +} from "@shared/config"; +import { Cache, gql } from "@shopify/hydrogen"; +import { ShopifyScripts } from "@shopify/hydrogen/react"; +import { HeadContent, Outlet, Scripts, createRootRoute, useNavigate } from "@tanstack/react-router"; +import type { ErrorComponentProps } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; + +import { AnalyticsTracker } from "~/components/AnalyticsTracker"; +import { CartAnalyticsTracker } from "~/components/CartAnalyticsTracker"; +import { CartDrawer } from "~/components/CartDrawer"; +import { ConsentBanner } from "~/components/ConsentBanner"; +import { Footer } from "~/components/Footer"; +import { Header } from "~/components/Header"; +import { CartProvider } from "~/lib/cart"; +import { routeTemplates } from "~/lib/route-templates"; + +import appCss from "./app.css?url"; + +const SHOP_ANALYTICS_QUERY = gql(` + query RootShopAnalytics { + shop { + id + name + description + } + } +`); + +/** + * Seed the cart and analytics metadata from the request-scoped Hydrogen + * context. The same context is shared by Shopify standard routes, server + * functions, and the SSR render for this request. + */ +// The cart query type intentionally carries open `unknown` index signatures so +// custom fragments can extend it. Its runtime value is JSON-safe, but that +// extensibility prevents TanStack's static serializer check from proving it. +const getRootData = createServerFn({ method: "GET", strict: { output: false } }).handler( + async ({ context }) => { + const { cartHandlers, customerAccount, storefrontClient } = context; + const cartPromise = cartHandlers + .get({ storefrontClient }) + .then((result) => ({ cart: result.data.cart ?? null, errors: result.data.errors })) + .catch((error) => { + console.error("[hydrogen] Cart seed failed", error); + return { cart: null }; + }); + + const shopFallback = { + shopId: `gid://shopify/Shop/${shop.shopId}`, + shopName: "CORE", + shopDescription: null, + }; + let shopId: string = shopFallback.shopId; + let shopName: string = shopFallback.shopName; + let shopDescription: string | null = shopFallback.shopDescription; + + try { + const { data, errors } = await storefrontClient.graphql(SHOP_ANALYTICS_QUERY, { + cache: Cache.long(), + signal: AbortSignal.timeout(2000), + }); + if (errors) console.error("[hydrogen] Root shop query failed", errors); + shopId = data?.shop?.id ?? shopId; + shopName = data?.shop?.name ?? shopName; + shopDescription = data?.shop?.description ?? null; + } catch (error) { + console.error("[hydrogen] Root shop query failed or timed out", error); + } + + const analyticsShop = { + shopId, + acceptedLanguage: analyticsShopConfig.acceptedLanguage, + currency: analyticsShopConfig.currency, + hydrogenSubchannelId: analyticsShopConfig.hydrogenSubchannelId, + }; + + const accountEnabled = customerAccount.available; + const isLoggedIn = accountEnabled + ? await customerAccount.session.isLoggedIn( + customerAccount.sessionManager, + customerAccount.requestContext, + ) + : false; + if (isLoggedIn) { + customerAccount.requestContext.markResponseAsPersonalized("customer-account"); + } + + return { + cartData: await cartPromise, + analyticsShop, + analyticsConsent: analyticsConsentConfig, + shopName, + shopDescription, + accountEnabled, + isLoggedIn, + }; + }, +); + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + ], + links: [ + { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { rel: "stylesheet", href: appCss }, + ], + }), + loader: () => getRootData(), + component: App, + shellComponent: RootDocument, + errorComponent: ErrorBoundary, + notFoundComponent: NotFoundPage, +}); + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ); +} + +function App() { + const loaderData = Route.useLoaderData(); + const navigate = useNavigate(); + + return ( + + + + + Skip to content + + +
+

Free shipping on orders over $50

+
+ +
+ +
+ +
+ +