Initial commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
Rami Bitar
2026-07-29 11:02:40 -04:00
co-authored by Claude Fable 5
commit f23332e42c
88 changed files with 7947 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# dependencies
/node_modules
# yarn
.yarn/install-state.gz
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts
# local CDN asset symlinks into workspace package dist folders
/public/storefront/analytics/shopify.js
/public/storefront/analytics/shopify.js.map
/public/storefront/webmcp.js
/public/storefront/webmcp.js.map
+84
View File
@@ -0,0 +1,84 @@
# Hydrogen — Next.js (App Router) example
A brand-new Next.js 16 (App Router, Turbopack, React 19.2) storefront example,
translating `examples/core` idiomatically into Next.js and bound to
`@shopify/hydrogen`. Runs on Vercel. Zero secrets required (mock.shop fallback).
## Scripts
- `pnpm --filter @shopify/hydrogen-example-nextjs dev` — dev server (Turbopack).
- `pnpm --filter @shopify/hydrogen-example-nextjs build` — production build.
- `pnpm --filter @shopify/hydrogen-example-nextjs start` — serve the build.
- `pnpm --filter @shopify/hydrogen-example-nextjs typecheck` — `tsc` +
`gql.tada check --fail-on-warn`.
## Architecture
- **Request lifecycle:** `proxy.ts` (`handleShopifyRoutes` pre-routing +
forwarded headers + mock.shop fallback) + `app/not-found.tsx`
(`handleShopifyRedirects` post-404).
- **Storefront client:** `getStorefrontClient()` (per-buyer, cart seed only) +
`staticStorefrontClient` (shared rate-limit, all catalog reads) — F2. A
browser-safe `publicStorefrontClient` (`lib/public-storefront.ts`) is provided
for future client-side Storefront fetches (e.g. TanStack Query); browser
predictive search currently goes through the same-origin
`/api/predictive-search` handler instead.
- **Layout:** shared code lives at the top level — `lib/` (storefront clients,
queries, fragments, cart, analytics, image, money, filters, markets) and
`components/` (Header, ProductCard, CartDrawer, …). `app/` holds only route
files. Imports use the `@/` alias (`tsconfig` `"@/*": ["./*"]`).
- **Caching:** Next-native `use cache` + `cacheLife`/`cacheTag` cache-points
keyed by serializable inputs (`cacheComponents: true`). No Oxygen LRU.
- **Cart seed (F1, F4):** root layout is a static shell wrapping an async
`AppShell` (cart seed via `Promise.race` + analytics shop) in `<Suspense>` —
the Cache Components idiom (static shell prerenders, per-buyer parts stream).
- **Markets:** `getMarketFromHeaders` reads `x-storefront-url`; the client
auto-injects `$country`/`$language` (never passed in query variables).
- **No-JS (F4):** variant GET-links switch variants server-side; cart reachable
via footer `/cart`; filter forms `method="get"` + explicit `action`.
## mock.shop fallback
When no `PRIVATE_STOREFRONT_API_TOKEN` is present, the example falls back to
`mock.shop` + `mock-private-token` so it runs with zero secrets. Decrypt
secrets (`pnpm examples:secrets:decrypt`) to hit a real store.
## Customer Accounts (local HTTPS + real store)
Customer Accounts require an HTTPS origin (Shopify OAuth rejects `http`) and a
real store (mock.shop has no Customer Account API).
One-time setup:
1. `pnpm https:setup` (repo root) — trusts `mkcert` and creates the
`.cert/localtest.me*` certificates.
2. `pnpm examples:secrets:decrypt` — provisions `PRIVATE_STOREFRONT_API_TOKEN`
so the example runs against a real store instead of mock.shop.
Run the HTTPS dev server and open <https://localtest.me:5173>:
```
pnpm --filter @shopify/hydrogen-example-nextjs https:dev
```
The `/account` page shows your name + email. `/account/login`,
`/account/logout`, `/account/refresh`, and `/account/authorize` are
Hydrogen-owned routes intercepted in `proxy.ts` (no app route files exist for
them) — Customer Account OAuth login/refresh/logout is handled there. The
header account link is hidden on mock.shop and shown only when a real store is
configured.
## Build note: `--debug-prerender`
The `build` script uses `next build --debug-prerender`. Next.js 16 +
React 19.2 has a confirmed framework bug
([vercel/next.js#84994](https://github.com/vercel/next.js/issues/84994),
[#86178](https://github.com/vercel/next.js/issues/86178),
[#94667](https://github.com/vercel/next.js/discussions/94667)) where the
internal `/_global-error` route fails to prerender with
`TypeError: Cannot read properties of null (reading 'useContext')`, blocking
`next build`. The bug reproduces with no custom error page and is independent
of this app's code; the fix requires React 19.3.0 (unreleased). The
`--debug-prerender` flag is the only available workaround and produces a
complete, valid Partial-Prerender production build (`next start` serves it
correctly). Remove the flag once React 19.3.0 ships.
+216
View File
@@ -0,0 +1,216 @@
import "server-only";
import { customerAccountConfig } from "@shared/config";
import { createCustomerAccountClient, gql } from "@shopify/hydrogen/customer-account";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getCustomerAccessToken } from "@/lib/customer-account";
import { isCustomerAccountsAvailable } from "@/lib/storefront-config";
import { toURLSearchParams } from "@/lib/url-params";
export const metadata: Metadata = {
title: "Account",
};
const CUSTOMER_QUERY = gql(`
query CurrentCustomer {
customer {
firstName
lastName
emailAddress {
emailAddress
}
}
}
`);
type AccountCustomer = {
firstName?: string | null;
lastName?: string | null;
emailAddress?: { emailAddress?: string | null } | null;
};
type AccountPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
/**
* `/account` — core Customer Accounts surface (name + email only).
*
* No `export const dynamic` / `export const fetchCache`: under
* `cacheComponents: true`, `force-dynamic` is not allowed. The page relies on
* the `AppShell` dynamic context (`await connection()` in the root layout's
* shell), which opts the whole subtree into dynamic rendering — any
* `headers()`/`cookies()` read inside (e.g. `getCustomerAccessToken()` →
* `createCurrentRequest()` → `headers()`) is then automatically dynamic, the
* same convention `cart/page.tsx` follows.
*
* Token refresh is delegated to the `/account/refresh` handler (intercepted in
* `proxy.ts`): the page only **reads** the token via `getAccessToken`
* (read-only, no mutation, no render-time commit — the proxy runs before RSC
* render and cannot commit mutations that occur during render). When no usable
* token is present, the page `redirect()`s to `/account/refresh?return_to=…`;
* the handler refreshes, commits the cookie on its response, and redirects
* back to `/account`, which now reads a valid token.
*/
export default async function AccountPage({ searchParams }: AccountPageProps) {
const available = isCustomerAccountsAvailable(); // sync — no await
const urlSearchParams = toURLSearchParams(await searchParams);
const loginFailed = urlSearchParams.get("login") === "failed";
const refreshAttempted = urlSearchParams.get("refreshed") === "1";
if (!available) return <AccountShell notice="real-store" />;
const { accessToken, requestContext } = await getCustomerAccessToken();
if (!accessToken && !loginFailed && !refreshAttempted) {
// Delegate the refresh to the `/account/refresh` handler instead of calling
// `getOrRefreshAccessToken` here: this page only *reads* the token during
// RSC render, and the proxy runs before render — it cannot commit a cookie
// mutation that happens during render. Refreshing in-page would break that
// no-render-commit invariant. The handler refreshes, sets the cookie on its
// own response, and redirects back with `refreshed=1`.
redirect(`/account/refresh?return_to=${encodeURIComponent("/account?refreshed=1")}`);
}
if (!accessToken) return <AccountShell loginFailed={loginFailed} />;
// Build the client per-call (Next has no RR "context" equivalent; per-call
// construction from `headers()` is the native Next approach, matching
// `getStorefrontClient`).
const customerAccount = createCustomerAccountClient({
shopId: customerAccountConfig.shopId,
requestContext,
});
// Keep the try/catch so a network/timeout failure surfaces as a friendly
// error card instead of an unhandled 500 in RSC render.
let customer: AccountCustomer | undefined;
let error: string | undefined;
try {
const { data, errors } = await customerAccount.graphql(CUSTOMER_QUERY, { accessToken });
customer = errors ? undefined : data.customer;
error = errors?.[0]?.message;
} catch {
error = "Customer Account API request failed. Try again later.";
}
return <AccountShell customer={customer} error={error} />;
}
type AccountShellProps =
| { notice: "real-store" }
| { loginFailed: boolean }
| { customer?: AccountCustomer | null; error?: string };
function AccountShell(props: AccountShellProps) {
return (
<div className="max-w-page px-margin mx-auto w-full py-16" aria-labelledby="account-heading">
<h1 id="account-heading" className="type-display mb-4">
Account
</h1>
<p className="type-body-lg text-on-surface-secondary max-w-xl">
Sign in with Shopify Customer Accounts to view your basic account identity.
</p>
{"notice" in props && <RealStoreNotice />}
{"loginFailed" in props && <LoginPanel loginFailed={props.loginFailed} />}
{"customer" in props &&
(props.error ? (
<CustomerAccountError message={props.error} />
) : (
<CustomerCard customer={props.customer} />
))}
</div>
);
}
function RealStoreNotice() {
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="notice-heading"
>
<h2 id="notice-heading" className="type-heading-lg mb-3">
Customer Accounts require a real store
</h2>
<p className="type-body text-on-surface-secondary max-w-xl">
This example is running against <code>mock.shop</code>, which has no Customer Account API.
Set your store credentials in <code>.env.local</code> and run{" "}
<code>yarn https:dev</code> to enable login.
</p>
</section>
);
}
function LoginPanel({ loginFailed }: { loginFailed: boolean }) {
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="login-heading"
>
<h2 id="login-heading" className="type-heading-lg mb-3">
Sign in
</h2>
<p className="type-body text-on-surface-secondary mb-6 max-w-xl">
Use your customer account to view your name and email for this store.
</p>
{loginFailed ? (
<p
tabIndex={-1}
role="alert"
autoFocus
className="border-border bg-surface-secondary text-on-surface focus-visible:outline-accent mb-6 rounded border p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
We could not complete your login. Try signing in again.
</p>
) : null}
{/* Plain `<a>` — `/account/login` is handler-intercepted in `proxy.ts`. */}
<a
href="/account/login"
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Log in
</a>
</section>
);
}
function CustomerAccountError({ message }: { message: string }) {
return (
<p
role="alert"
className="border-border bg-surface-secondary text-on-surface mt-8 rounded border p-4"
>
{message}
</p>
);
}
function CustomerCard({ customer }: { customer?: AccountCustomer | null }) {
const name = [customer?.firstName, customer?.lastName].filter(Boolean).join(" ") || "Customer";
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="identity-heading"
>
<h2
id="identity-heading"
className="type-body text-on-surface-secondary mb-3 text-sm font-semibold tracking-[0.2em] uppercase"
>
Customer identity
</h2>
<p className="type-heading-lg mb-2">{name}</p>
{customer?.emailAddress?.emailAddress ? (
<p className="type-body text-on-surface-secondary">{customer.emailAddress.emailAddress}</p>
) : null}
<form method="post" action="/account/logout" className="mt-8">
<button
type="submit"
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Log out
</button>
</form>
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
import "server-only";
import { analyticsConsent as analyticsConsentConfig } from "@shared/config";
import { connection } from "next/server";
import { Suspense } from "react";
import { CartDrawer } from "@/components/CartDrawer";
import { ConsentBanner } from "@/components/ConsentBanner";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { HeaderAccountLink, HeaderAccountLinkFallback } from "@/components/HeaderAccountLink";
import { ShopifyScriptsClient } from "@/components/ShopifyScriptsClient";
import { getAnalyticsShop, getScriptsShop } from "@/lib/analytics-shop";
import { cartHandlers } from "@/lib/cart-handlers";
import { getStorefrontClient } from "@/lib/storefront";
import { Providers } from "./providers";
/**
* Async server shell that owns the per-request (dynamic) reads: the cart seed
* + the shop analytics GID. With `cacheComponents: true`, uncached/dynamic data
* accessed in a Server Component must sit inside a `<Suspense>` boundary so the
* static HTML shell prerenders and the per-buyer parts stream
* (`next/server` `connection()` + `headers()`/`cookies()` are per-request).
*
* Rendered inside `<Suspense>` from the root layout. `await connection()`
* opts the subtree into dynamic rendering (resolves immediately on a real
* request, never during prerender).
*/
export async function AppShell({ children }: { children: React.ReactNode }) {
await connection();
// Cart seed: per-buyer `getStorefrontClient()` (skill-mandated; the cart is
// personalized). Non-blocking via `Promise.race` (F1). On success pass the
// full `{cart, errors?}` envelope; on timeout/error pass `undefined` so the
// client fetches `/api/cart` after hydration (NOT `{cart:null}` — that would
// suppress the retry).
const storefrontClient = await getStorefrontClient();
let cartData: Awaited<ReturnType<typeof cartHandlers.get>>["data"] | undefined;
try {
const result = await Promise.race([
cartHandlers.get({ storefrontClient }),
timeoutReject(2000),
]);
cartData = result.data;
} catch (error) {
console.error("[hydrogen] Cart seed failed or timed out", error);
cartData = undefined;
}
// Analytics shop GID: best-effort, non-blocking (F1). Merged with
// `@shared/config` metadata (acceptedLanguage/currency/hydrogenSubchannelId).
const analyticsShop = await getAnalyticsShop();
const scriptsShop = getScriptsShop();
return (
<Providers
cartData={cartData}
analyticsShop={analyticsShop}
analyticsConsent={analyticsConsentConfig}
>
<a
href="#main-content"
className="focus-visible:bg-interactive focus-visible:text-interactive-text sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:start-4 focus-visible:top-4 focus-visible:z-50 focus-visible:rounded focus-visible:px-4 focus-visible:py-2"
>
Skip to content
</a>
<Header
accountLink={
<Suspense fallback={<HeaderAccountLinkFallback />}>
<HeaderAccountLink />
</Suspense>
}
/>
<main className="flex-1" id="main-content" tabIndex={-1}>
{children}
</main>
<Footer />
<CartDrawer />
<ConsentBanner />
<ShopifyScriptsClient shop={scriptsShop} />
</Providers>
);
}
function timeoutReject(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`cart seed timed out after ${ms}ms`)), ms);
});
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { CartCheckoutButton } from "@/components/CartCheckoutButton";
import { CartContent } from "@/components/CartContent";
import { CartViewedTracker } from "@/components/CartViewedTracker";
import { content } from "@/lib/content";
import { canonicalUrl } from "@/lib/site";
export const metadata: Metadata = {
title: "Cart",
description: content.cart.title,
alternates: { canonical: "/cart" },
openGraph: {
title: "Cart — CORE",
type: "website",
url: canonicalUrl("/cart"),
},
};
/**
* `/cart` — a real server-rendered cart page that is the drawer's no-JS fallback
* (engineering.md F4 + `notes/cart.md`). Renders `CartContent` (client) INSIDE
* the root layout's existing `CartProvider` — does **NOT** re-mount
* `CartProvider` and does **NOT** re-fetch the cart. The root layout's server
* seed already populates the SSR HTML (the no-JS contract). A per-page second
* `CartProvider` would create a divergent inner store vs the root-rendered
* `CartDrawer` — mutations would not reflect across them.
*
* `force-dynamic` is not allowed with `cacheComponents: true`; the root layout
* already makes every route dynamic via `cookies()`/`headers()` in the cart
* seed. Reachable via the footer `/cart` link (no-JS fallback).
*/
export default function CartPage() {
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<h1 className="type-display mb-8">{content.cart.title}</h1>
<div className="mx-auto max-w-2xl">
<CartContent />
<CartCheckoutButton />
<CartViewedTracker />
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { parseCollectionParams, type AvailableFilter } from "@shopify/hydrogen";
import type { ProductFilter as StorefrontApiProductFilter } from "@shopify/hydrogen/storefront-api-types";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { COLLECTION_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
type Props = {
params: Promise<{ handle: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { handle } = await params;
const { collection } = await fetchCollection(handle, undefined, "");
const title = collection?.title ?? "Collection";
const description = collection?.description ?? "";
return {
title,
description,
alternates: { canonical: `/collections/${handle}` },
openGraph: {
title: `${title} — CORE`,
description,
type: "website",
url: canonicalUrl(`/collections/${handle}`),
},
};
}
async function fetchCollection(
handle: string,
after: string | undefined,
searchString: string,
): Promise<{
collection: Awaited<ReturnType<typeof query>>["collection"] | null;
products: Awaited<ReturnType<typeof query>>["products"];
availableFilters: AvailableFilter[];
pageInfo: { hasNextPage: boolean; endCursor?: string | null };
}> {
"use cache";
cacheLife("minutes");
cacheTag("collections", "products");
// Reconstruct URLSearchParams from the serialized search string. `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get` — pass a
// plain string across the cache boundary and parse inside.
const browse = parseCollectionParams(new URLSearchParams(searchString));
const result = await query(handle, after, browse.filters, browse.sortKey, browse.reverse);
return result;
}
async function query(
handle: string,
after: string | undefined,
filters: ReturnType<typeof parseCollectionParams>["filters"],
sortKey: ReturnType<typeof parseCollectionParams>["sortKey"],
reverse: boolean,
) {
const { data, errors } = await staticStorefrontClient.graphql(COLLECTION_QUERY, {
variables: {
handle,
first: 24,
after,
filters:
filters.length > 0
? // F13: skill-sanctioned generated-type cast at the query variable
// boundary (hydrogen-collection-browser/references/react.md).
(filters as StorefrontApiProductFilter[])
: undefined,
sortKey,
reverse: reverse || undefined,
},
});
if (errors) {
console.error("[hydrogen] Collection query failed", errors);
}
const collection = data?.collection ?? null;
return {
collection,
products: collection?.products.nodes ?? [],
availableFilters: collection?.products.filters ?? [],
pageInfo: collection?.products.pageInfo ?? { hasNextPage: false },
};
}
export default async function CollectionPage({ params, searchParams }: Props) {
const { handle } = await params;
const urlSearch = toURLSearchParams(await searchParams);
const after = urlSearch.get("after") ?? undefined;
const { collection, products, availableFilters, pageInfo } = await fetchCollection(
handle,
after,
urlSearch.toString(),
);
// 404 only when the query has no GraphQL errors and the collection is missing
// (F8: don't blanket-404 on a Storefront auth error — errors are logged above).
if (!collection) {
notFound();
}
return (
<CollectionBrowser
mode="collection"
handle={collection.handle}
collection={{
id: collection.id,
handle: collection.handle,
title: collection.title,
description: collection.description,
descriptionHtml: collection.descriptionHtml,
}}
products={products}
availableFilters={availableFilters}
pageInfo={pageInfo}
dataSearch={urlSearch.toString()}
/>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { CollectionCard } from "@/components/CollectionCard";
import { content } from "@/lib/content";
import { COLLECTIONS_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
export const metadata: Metadata = {
title: "Collections",
description: "Browse all collections",
alternates: { canonical: "/collections" },
openGraph: {
title: "Collections — CORE",
type: "website",
url: canonicalUrl("/collections"),
},
};
async function fetchCollections(after?: string) {
"use cache";
cacheLife("hours");
cacheTag("collections");
const { data, errors } = await staticStorefrontClient.graphql(COLLECTIONS_QUERY, {
variables: { first: 24, after },
});
if (errors) {
console.error("[hydrogen] Collections query failed", errors);
}
return {
collections: data?.collections?.nodes ?? [],
pageInfo: data?.collections?.pageInfo ?? { hasNextPage: false },
};
}
export default async function CollectionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const after = typeof params.after === "string" ? params.after : undefined;
const { collections, pageInfo } = await fetchCollections(after);
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs items={[{ label: content.collections.title }]} />
</div>
<h1 className="type-display mb-8">{content.collections.title}</h1>
<ul role="list" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((collection, index) => (
<li key={collection.id}>
<CollectionCard
collection={collection}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
{pageInfo.hasNextPage ? (
<div className="mt-12 text-center">
<Link
href={`/collections?after=${encodeURIComponent(pageInfo.endCursor ?? "")}`}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Load more
</Link>
</div>
) : null}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import { content } from "@/lib/content";
/**
* Global error boundary (engineering.md F8). Next.js renders this when a route
* or layout throws. Must be a client component (`'use client'`) and render a
* `<html>`/`<body>` shell when used as `app/global-error.tsx`; this segment
* error boundary renders inside the existing root layout, so it only needs the
* error UI + a `reset` button.
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[hydrogen-example-nextjs] Route error:", error);
}, [error]);
return (
<div className="max-w-page px-margin mx-auto py-16">
<h1 className="type-heading-xl mb-4">Something went wrong</h1>
<p className="type-body text-on-surface-secondary mb-2">{error.message}</p>
{error.digest ? (
<p className="text-on-surface-secondary mb-8 text-sm">Error digest: {error.digest}</p>
) : null}
<button
type="button"
onClick={reset}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.general.back}
</button>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* Global error boundary (`app/global-error.tsx`) — renders when the root layout
* itself throws (engineering.md F8). Unlike `app/error.tsx` (a segment boundary
* that renders inside the root layout), this must render its own
* `<html>`/`<body>` shell and must NOT depend on any provider/context from the
* root layout (the layout is the thing that threw).
*
* Kept intentionally minimal (pure HTML + inline styles, no hooks that consume
* global context) to avoid the known Next.js 16 + React 19.2 `/_global-error`
* prerender bug (vercel/next.js#84994) where `AppRouterContext` is null during
* static generation of the internal error page.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body style={{ margin: 0, padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<div style={{ maxWidth: "40rem", margin: "0 auto" }}>
<h1 style={{ fontSize: "1.875rem", fontWeight: 300, marginBottom: "1rem" }}>
Something went wrong
</h1>
<p style={{ color: "#6b7280", marginBottom: "0.5rem" }}>{error.message}</p>
{error.digest ? (
<p style={{ color: "#6b7280", fontSize: "0.875rem", marginBottom: "2rem" }}>
Error digest: {error.digest}
</p>
) : null}
<button
type="button"
onClick={reset}
style={{
height: "2.75rem",
padding: "0 1.25rem",
borderRadius: "0.5rem",
border: "none",
background: "#1b4332",
color: "#fff",
fontSize: "0.875rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}
+951
View File
@@ -0,0 +1,951 @@
@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 React Router example's components can rely on the same
cross-example class anchors (button-primary, badge-sale, type-display,
overlay-dark, drawer-right, dialog-center, consent-banner, etc.).
*/
@theme {
/* Surfaces */
--color-surface: #ffffff;
--color-surface-secondary: #e3e3e3;
--color-on-surface: #000000;
--color-on-surface-secondary: #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;
}
}
.remove-icon-top,
.remove-icon-bottom {
transition: transform 125ms ease-in-out;
}
}
@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;
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import "./globals.css";
import { SITE_ORIGIN } from "@/lib/site";
import { AppShell } from "./app-shell";
/**
* Root layout (engineering.md F1, F4, F7, F10). With `cacheComponents: true`,
* the layout is a **static shell** — it prerenders the `<html>`/`<body>` +
* announcement bar, then wraps the per-request (dynamic) `AppShell` (cart seed
* + analytics shop + chrome) in `<Suspense>` so the dynamic parts stream while
* the static shell serves immediately. `AppShell` calls `connection()` to opt
* the subtree into dynamic rendering.
*
* `metadataBase` is set here for canonical/OG URL resolution (F10).
*/
export const metadata: Metadata = {
metadataBase: new URL(SITE_ORIGIN),
title: {
default: "CORE — Discover our latest collection",
template: "%s — CORE",
},
description: "Explore our curated selection of premium products",
icons: {
icon: { url: "/favicon.svg", type: "image/svg+xml" },
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-surface text-on-surface font-body flex min-h-svh flex-col antialiased">
<div
role="region"
aria-label="Announcement"
className="bg-on-surface px-margin py-2.5 text-center"
>
<p className="type-body-sm text-surface">Free shipping on orders over $50</p>
</div>
<Suspense
fallback={
<div className="bg-surface text-on-surface-secondary flex-1" aria-busy="true" />
}
>
<AppShell>{children}</AppShell>
</Suspense>
</body>
</html>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { handleShopifyRedirects } from "@shopify/hydrogen";
import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { routeTemplates } from "@/lib/route-templates";
import { getStorefrontClient } from "@/lib/storefront";
/**
* Next.js 404 + Shopify URL redirects (`hydrogen-request-handlers` /
* `references/nextjs.md`). `proxy.ts` cannot inspect the routed response, so
* Storefront URL redirects run here, after the framework returns a 404.
*
* With `cacheComponents: true`, the per-request reads (`headers()` +
* `getStorefrontClient()` + `redirect()`) must sit inside a `<Suspense>`
* boundary. The static 404 shell prerenders; the `<RedirectChecker>` streams
* and either `redirect()`s to a matching Shopify URL redirect or renders
* nothing (leaving the 404 shell visible).
*
* The forwarded `x-storefront-url` header (set by `proxy.ts`) carries the
* original URL so `handleShopifyRedirects` can match it.
*
* Gotcha (N2): Next's `redirect()` does not preserve Hydrogen's `301` status.
*/
export default function NotFound() {
return (
<main className="max-w-page px-margin mx-auto w-full py-16 text-center">
<Suspense fallback={null}>
<RedirectChecker />
</Suspense>
<h1 className="type-display mb-4">Page not found</h1>
<p className="type-body text-on-surface-secondary mb-8">
The page you’re looking for doesn’t exist.
</p>
<Link
href="/"
className="rounded-button button-primary inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline"
>
Back to home
</Link>
</main>
);
}
async function RedirectChecker() {
await connection();
const requestHeaders = await headers();
const url = requestHeaders.get("x-storefront-url");
if (url) {
const result = await handleShopifyRedirects({
request: new Request(url),
storefrontClient: await getStorefrontClient(),
routeTemplates,
});
const location = result?.headers.get("location");
if (location) redirect(location);
}
return null;
}
+169
View File
@@ -0,0 +1,169 @@
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { CollectionCard } from "@/components/CollectionCard";
import { ProductCard } from "@/components/ProductCard";
import { content } from "@/lib/content";
import { HOME_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
export const metadata: Metadata = {
title: "CORE — Discover our latest collection",
description: content.home.hero.subtitle,
alternates: { canonical: "/" },
openGraph: {
title: "CORE — Discover our latest collection",
description: content.home.hero.subtitle,
type: "website",
url: canonicalUrl("/"),
},
twitter: { card: "summary_large_image" },
};
/** Home catalog fetch — cached at the data boundary (F2: dynamic root layout
* precludes page-level prerender, so the cache-point is the fetch function). */
async function fetchHome() {
"use cache";
cacheLife("minutes");
cacheTag("products", "collections");
const { data, errors } = await staticStorefrontClient.graphql(HOME_QUERY);
if (errors) {
console.error("[hydrogen] Home query failed", errors);
}
return {
featuredProducts: data?.featuredProducts?.nodes ?? [],
featuredCollections: data?.featuredCollections?.nodes ?? [],
};
}
type HomeData = Awaited<ReturnType<typeof fetchHome>>;
type ProductNode = HomeData["featuredProducts"][number];
type CollectionNode = HomeData["featuredCollections"][number];
export default async function HomePage() {
const { featuredProducts, featuredCollections } = await fetchHome();
return (
<>
<Hero />
<BestSellers products={featuredProducts} />
<ShopByCategory collections={featuredCollections} />
</>
);
}
function Hero() {
// The hero is the LCP image (notes/home.md): eager + high fetch priority.
// Unsplash is a third-party host, so it passes through `shopifyImageUrl`
// unchanged — build a width-descriptor srcset directly from its `w=` param
// and pair it with `sizes="100vw"` (F12). DPR `srcSetFor` is not used here:
// `sizes` is a no-op for 1x/2x descriptors.
const heroBaseUrl =
"https://images.unsplash.com/photo-1653398597732-37fc919284dd?auto=format&fit=crop&q=80";
const heroSrcSet = `${heroBaseUrl}&w=480 480w, ${heroBaseUrl}&w=1024 1024w, ${heroBaseUrl}&w=2000 2000w`;
return (
<section className="max-w-page px-margin mx-auto w-full">
<div
className="bleed-full min-h-hero relative overflow-hidden"
aria-labelledby="hero-heading"
>
<div className="bg-surface-secondary absolute inset-0">
<img
src={`${heroBaseUrl}&w=2000`}
srcSet={heroSrcSet}
sizes="100vw"
alt="A white chair beside a white wall"
className="h-full w-full object-cover"
loading="eager"
fetchPriority="high"
/>
</div>
<div className="overlay-dark pointer-events-none absolute inset-0" />
<div className="max-w-page px-margin text-interactive-text min-h-hero relative z-10 mx-auto flex flex-col items-start justify-end p-8 pb-12">
<h1 id="hero-heading" className="type-display mb-3 max-w-2xl">
{content.home.hero.heading}
</h1>
<p className="type-body-lg mb-6 max-w-prose opacity-90">{content.home.hero.subtitle}</p>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/collections"
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center gap-2 px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.home.hero.primaryCta}
</Link>
<Link
href="/collections"
className="rounded-button button-secondary focus-visible:outline-accent inline-flex h-11 items-center justify-center gap-2 px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.home.hero.secondaryCta}
</Link>
</div>
</div>
</div>
</section>
);
}
function BestSellers({ products }: { products: ProductNode[] }) {
return (
<section className="bg-surface w-full pt-20 pb-12">
<div className="max-w-page px-margin mx-auto mb-4 flex items-center justify-between">
<h2 className="type-heading-xl">{content.home.bestSellers}</h2>
<Link
href="/collections"
className="min-h-touch-target text-on-surface focus-visible:outline-accent inline-flex items-center gap-1 rounded-sm text-sm font-normal no-underline hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-opacity"
>
<span>{content.home.viewAll}</span>
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</Link>
</div>
<div className="max-w-page px-margin mx-auto contain-paint">
<ul role="list" className="grid grid-cols-1 gap-x-1 gap-y-10 md:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<li key={product.id}>
<ProductCard product={product} loading="lazy" />
</li>
))}
</ul>
</div>
</section>
);
}
function ShopByCategory({ collections }: { collections: CollectionNode[] }) {
return (
<section className="w-full py-12" aria-labelledby="category-heading">
<h2 id="category-heading" className="type-heading-xl max-w-page px-margin mx-auto mb-4">
{content.home.shopByCategory}
</h2>
<ul role="list" className="max-w-page px-margin mx-auto grid grid-cols-1 md:grid-cols-3">
{collections.map((collection) => (
<li key={collection.id}>
<CollectionCard collection={collection} loading="lazy" />
</li>
))}
</ul>
</section>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { getSelectedProductOptions } from "@shopify/hydrogen";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ProductCard, type ProductCardData } from "@/components/ProductCard";
import { ProductDetails } from "@/components/ProductDetails";
import { content } from "@/lib/content";
import { PRODUCT_QUERY, RELATED_PRODUCTS_QUERY, type ProductData } from "@/lib/product-query";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
type Props = {
params: Promise<{ handle: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { handle } = await params;
// Re-fetch the product for metadata. It hits the same `use cache` cache-point
// as the page render, so this is a cache hit in practice.
const { product } = await fetchProduct(handle, "");
const title = product?.title ?? "Product";
return {
title,
description: product?.description ?? "",
// Variant params do NOT change the canonical (F10).
alternates: { canonical: `/products/${handle}` },
openGraph: {
title: `${title} — CORE`,
description: product?.description ?? "",
url: canonicalUrl(`/products/${handle}`),
},
twitter: { card: "summary_large_image" },
};
}
async function fetchProduct(
handle: string,
searchString: string,
): Promise<{ product: ProductData | null }> {
"use cache";
cacheLife("minutes");
cacheTag("products");
// Read variant selection from URL option params (F4: a no-JS GET to
// `?Size=Large&Color=Green` resolves the variant server-side). Do NOT pass
// `optionNames: []` — an empty allow-list filters out every param.
// Reconstruct URLSearchParams from the serialized search string — `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get`.
const selectedOptions = getSelectedProductOptions(new URLSearchParams(searchString));
const { data, errors } = await staticStorefrontClient.graphql(PRODUCT_QUERY, {
variables: { handle, selectedOptions },
});
if (errors) {
console.error("[hydrogen] Product query failed", errors);
}
return { product: data?.product ?? null };
}
export default async function ProductPage({ params, searchParams }: Props) {
const { handle } = await params;
const urlSearch = toURLSearchParams(await searchParams);
const { product } = await fetchProduct(handle, urlSearch.toString());
// 404 only when the query has no GraphQL errors and the product is missing
// (F8: errors are logged above; don't blanket-404 on a Storefront auth error).
if (!product) {
notFound();
}
return (
<>
<ProductDetails product={product} />
{/* Best-effort related products (F14): a separate async server child in a
<Suspense> boundary that degrades silently — never blocks the PDP. */}
<Suspense fallback={null}>
<RelatedProducts handle={handle} />
</Suspense>
</>
);
}
/**
* Related products (`notes/product.md` "you may also like"). Best-effort: any
* error/timeout returns an empty list so the PDP never breaks. Fetched via the
* shared `staticStorefrontClient` inside a `use cache` cache-point.
*/
async function RelatedProducts({ handle }: { handle: string }) {
"use cache";
cacheLife("minutes");
cacheTag("products");
let related: ProductCardData[] = [];
try {
const { data, errors } = await staticStorefrontClient.graphql(RELATED_PRODUCTS_QUERY, {
variables: { handle },
});
if (errors) {
console.error("[hydrogen] Related products query failed", errors);
}
if (data?.product?.relatedProducts) {
const all = data.product.relatedProducts.nodes.flatMap((node) => node.products.nodes);
related = all.filter((p) => p.handle !== handle).slice(0, 4);
}
} catch (error) {
// Related products are an enhancement; never break the PDP.
console.error("[hydrogen] Related products failed", error);
}
if (related.length === 0) return null;
return (
<section className="max-w-page px-margin mx-auto mt-16">
<h2 className="type-heading-xl mb-6">{content.product.relatedProducts}</h2>
<ul role="list" className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-4">
{related.map((relatedProduct) => (
<li key={relatedProduct.id}>
<ProductCard product={relatedProduct} loading="lazy" />
</li>
))}
</ul>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import type { ConsentConfig, ShopAnalytics } from "@shopify/hydrogen";
import { Suspense } from "react";
import type { ReactNode } from "react";
import { AnalyticsTracker } from "@/components/AnalyticsTracker";
import { CartAnalyticsTracker } from "@/components/CartAnalyticsTracker";
import { CartProvider } from "@/lib/cart";
import type { cartHandlers } from "@/lib/cart-handlers";
/**
* Client `CartProvider` wrapper + root analytics trackers
* (`hydrogen-cart-ui` / `references/react.md` Next.js App Router section +
* `hydrogen-analytics` / `references/react.md`). The root server layout reads
* the cart seed and the analytics shop GID on the server, then passes them here.
*
* `cartData` is the full handler data envelope (`{cart, errors?}`). It is
* `undefined` when the server cart read timed out or errored — **not**
* `{cart: null}`. `{cart: null}` means "server confirmed no usable cart"
* (suppresses the post-hydration `/api/cart` fetch); `undefined` means "no
* bootstrap → fetch `/api/cart` after hydration" so the count self-corrects.
* `CartProvider` accepts `undefined` (F1).
*
* `AnalyticsTracker` reads `usePathname` + `useSearchParams`; it stays inside
* the `<Suspense>` boundary the layout wraps around `Providers` so the
* `useSearchParams()` CSR bailout is scoped to the tracker, not the layout.
*/
export type CartData = Awaited<ReturnType<typeof cartHandlers.get>>["data"];
export function Providers({
cartData,
analyticsShop,
analyticsConsent,
children,
}: {
cartData?: CartData;
analyticsShop: ShopAnalytics;
analyticsConsent: ConsentConfig;
children: ReactNode;
}) {
return (
<CartProvider initialData={cartData}>
{/* Suspense scopes the `useSearchParams()` CSR bailout to the tracker,
not the whole layout (hydrogen-analytics / references/react.md). */}
<Suspense fallback={null}>
<AnalyticsTracker shop={analyticsShop} consent={analyticsConsent} />
</Suspense>
<CartAnalyticsTracker />
{children}
</CartProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
import { SITE_ORIGIN } from "@/lib/site";
/**
* `/robots.txt` (engineering.md F10). Allows all crawlers, points to the
* sitemap, and disallows the cart + API surfaces.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/cart", "/api/"],
},
sitemap: `${SITE_ORIGIN}/sitemap.xml`,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { parseCollectionParams, type StorefrontApi } from "@shopify/hydrogen";
import type { AvailableFilter } from "@shopify/hydrogen";
import type { ProductFilter as StorefrontApiProductFilter } from "@shopify/hydrogen/storefront-api-types";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { SEARCH_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
export const metadata: Metadata = {
title: "Search",
description: "Search products",
alternates: { canonical: "/search" },
openGraph: {
title: "Search — CORE",
type: "website",
url: canonicalUrl("/search"),
},
};
type SearchResult = {
term: string;
products: SearchProductNode[];
availableFilters: AvailableFilter[];
pageInfo: { hasNextPage: boolean; endCursor?: string | null };
totalCount: number;
};
/** A `search.nodes` element narrowed to the `Product` branch (includes the
* `__typename` discriminator + the `ProductCard` fragment fields). */
type SearchNode = NonNullable<
StorefrontApi.ResultOf<typeof SEARCH_QUERY>["search"]
>["nodes"][number];
type SearchProductNode = Extract<SearchNode, { __typename: "Product" }>;
async function fetchSearch(term: string, searchString: string): Promise<SearchResult> {
"use cache";
cacheLife("minutes");
cacheTag("products");
if (!term) {
return {
term: "",
products: [],
availableFilters: [],
pageInfo: { hasNextPage: false, endCursor: null },
totalCount: 0,
};
}
// Reconstruct URLSearchParams from the serialized search string. `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get` — pass a
// plain string across the cache boundary and parse inside.
const searchParams = new URLSearchParams(searchString);
const browse = parseCollectionParams(searchParams);
// parseCollectionParams returns a ProductCollectionSortKeys; search only
// supports PRICE/RELEVANCE. Map unsupported sorts back to RELEVANCE.
const searchSortKey = browse.sortKey === "PRICE" ? "PRICE" : "RELEVANCE";
const { data, errors } = await staticStorefrontClient.graphql(SEARCH_QUERY, {
variables: {
query: term,
first: 24,
after: searchParams.get("after") ?? undefined,
sortKey: searchSortKey,
reverse: browse.reverse || undefined,
productFilters:
browse.filters.length > 0
? // F13: skill-sanctioned generated-type cast at the query variable
// boundary (hydrogen-collection-browser/references/react.md).
(browse.filters as StorefrontApiProductFilter[])
: undefined,
},
});
if (errors) {
console.error("[hydrogen] Search query failed", errors);
}
const search = data?.search;
const products = search?.nodes ?? [];
// Filter to Product nodes only (search is heterogeneous; `__typename` guard
// per feedback Round 1 + Round 2 #1).
const productNodes = products.filter(
(node): node is SearchProductNode => node?.__typename === "Product",
);
return {
term,
products: productNodes,
availableFilters: search?.productFilters ?? [],
pageInfo: search?.pageInfo ?? { hasNextPage: false },
totalCount: productNodes.length,
};
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const urlSearch = toURLSearchParams(await searchParams);
const term = urlSearch.get("q")?.trim() ?? "";
const result = await fetchSearch(term, urlSearch.toString());
return (
<CollectionBrowser
mode="search"
term={result.term}
products={result.products}
availableFilters={result.availableFilters}
pageInfo={result.pageInfo}
totalCount={result.totalCount}
dataSearch={urlSearch.toString()}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { MetadataRoute } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { SITEMAP_QUERY } from "@/lib/queries";
import { SITE_ORIGIN } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
/**
* `/sitemap.xml` (engineering.md F10). Lists product + collection URLs with
* `updatedAt` timestamps, fetched via the shared `staticStorefrontClient`
* inside a `use cache` cache-point (catalog, not personalized). mock.shop
* fallback works (queries mock.shop).
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries = await fetchSitemap();
return entries.map((entry) => ({
url: entry.loc,
lastModified: entry.lastmod ?? undefined,
}));
}
type SitemapEntry = { loc: string; lastmod?: string };
async function fetchSitemap(): Promise<SitemapEntry[]> {
"use cache";
cacheLife("hours");
cacheTag("products", "collections");
const { data, errors } = await staticStorefrontClient.graphql(SITEMAP_QUERY);
if (errors) {
console.error("[hydrogen] Sitemap query failed", errors);
}
const entries: SitemapEntry[] = [
{ loc: `${SITE_ORIGIN}/` },
{ loc: `${SITE_ORIGIN}/collections` },
{ loc: `${SITE_ORIGIN}/search` },
];
for (const collection of data?.collections?.nodes ?? []) {
entries.push({
loc: `${SITE_ORIGIN}/collections/${collection.handle}`,
lastmod: collection.updatedAt ?? undefined,
});
}
for (const product of data?.products?.nodes ?? []) {
entries.push({
loc: `${SITE_ORIGIN}/products/${product.handle}`,
lastmod: product.updatedAt ?? undefined,
});
}
return entries;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import type { ConsentConfig, ShopAnalytics } from "@shopify/hydrogen";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { AnalyticsEvent, configureAnalytics, getAnalytics } from "@/lib/analytics";
/**
* Root analytics tracker (`hydrogen-analytics` / `references/react.md` Next.js
* App Router shape). Keys the `PAGE_VIEWED` effect by `pathname + "?" + search`
* so client navigations fire a fresh page view (F9: no polling). Wrapped in
* `<Suspense>` in `Providers` so the `useSearchParams()` CSR bailout is scoped
* to the tracker, not the whole layout.
*/
export function AnalyticsTracker({
shop,
consent,
}: {
shop: ShopAnalytics;
consent: ConsentConfig;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const pageKey = `${pathname}?${searchParams?.toString() ?? ""}`;
useEffect(() => {
configureAnalytics(shop, consent);
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.PAGE_VIEWED);
}, [pageKey, shop, consent]);
return null;
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
export type Crumb = {
label: string;
href?: string;
};
type BreadcrumbsProps = {
items: Crumb[];
};
/**
* Server-rendered breadcrumb trail. The last crumb is `aria-current="page"`.
* Reused by collection, collections, search, and product routes (F13).
*/
export function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb">
<ol
role="list"
className="text-on-surface-secondary flex flex-wrap items-center gap-1 text-sm"
>
<li key="home">
<Link href="/" className="hover:text-on-surface no-underline">
Home
</Link>
</li>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={`${item.label}-${index}`} className="flex items-center gap-1">
<span aria-hidden="true">/</span>
{item.href && !isLast ? (
<Link href={item.href} className="hover:text-on-surface no-underline">
{item.label}
</Link>
) : (
<span
className="text-on-surface"
{...(isLast ? { "aria-current": "page" as const } : {})}
>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
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<string | null>(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;
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCart } from "@/lib/cart";
import { content } from "@/lib/content";
/**
* `/cart` checkout button — reads the cart store (client) for `checkoutUrl` and
* `totalQuantity`. Hidden when the cart is empty. Rendered as a client island
* inside the server-rendered `/cart` page so it reflects the live cart.
*/
export function CartCheckoutButton() {
const totalQuantity = useCart((state) => state.data.totalQuantity);
const checkoutUrl = useCart((state) => state.data.checkoutUrl);
const isEmpty = totalQuantity === 0;
if (isEmpty || !checkoutUrl) return null;
return (
<div className="border-border mt-6 border-t pt-4">
<a
href={checkoutUrl}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 w-full items-center justify-center px-4 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.cart.checkout}
</a>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
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 subtotal = cart.cost.subtotalAmount;
const discountCodes = cart.discountCodes;
const { formProps, register } = useCartForm();
if (loading) {
return (
<div className="divide-border divide-y" aria-busy="true">
<p className="text-on-surface-secondary py-8 text-center text-sm">Loading cart…</p>
</div>
);
}
if (totalQuantity === 0 || lines.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<p className="type-body-sm text-on-surface font-medium">{content.cart.empty}</p>
<p className="text-on-surface-secondary text-sm">{content.cart.emptyDescription}</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<ul role="list" className="divide-border min-h-0 flex-1 divide-y overflow-y-auto">
{lines.map((line) => (
<li key={line.id}>
<CartLineItem line={line} />
</li>
))}
</ul>
<div className="border-border mt-3 shrink-0 border-t pt-3">
<div className="flex flex-col gap-3">
{/* Discount apply form */}
<form {...formProps()} className="flex gap-2">
<input
type="text"
{...register("discountCode", { defaultValue: "" })}
placeholder="Discount code"
aria-label="Discount code"
className="number-reset rounded-button border-border h-11 flex-1 border px-3 text-sm"
/>
<button
type="submit"
{...register("discount-apply")}
className="rounded-button button-secondary inline-flex h-11 items-center justify-center px-4 text-sm font-medium"
>
Apply
</button>
</form>
{/* Applied discount codes — each removal is its own form */}
{discountCodes.map((code) => (
<form {...formProps()} key={code.code} className="flex items-center gap-2">
<input type="hidden" {...register("discountCode", { value: code.code })} />
<span className="chip-filled rounded-full px-3 py-1 text-sm">{code.code}</span>
<button
type="submit"
{...register("discount-remove")}
className="button-icon rounded text-sm"
aria-label={`Remove discount ${code.code}`}
>
<img
src="/icons/icon-x.svg"
width="16"
height="16"
alt=""
className="size-4"
aria-hidden="true"
/>
</button>
</form>
))}
{/* Estimated total */}
<div className="flex items-center justify-between">
<span className="text-on-surface text-sm font-medium">{content.cart.totalLabel}</span>
<span className="text-on-surface text-base font-medium">{formatPrice(subtotal)}</span>
</div>
<p className="text-on-surface-secondary text-xs">
{content.cart.taxesAndShippingAtCheckout}
</p>
</div>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
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 `<dialog>` + `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 (
<dialog
id={CART_DRAWER_ID}
className="drawer-right bg-surface text-on-surface"
aria-labelledby="cart-drawer-title"
closedby="any"
>
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center py-2 ps-4 pe-2">
<div className="flex flex-1 items-center gap-2">
<h2 id="cart-drawer-title" className="text-on-surface text-lg font-medium">
{content.cart.title}
</h2>
<span className="cart-count-badge" aria-hidden="true">
{totalQuantity}
</span>
</div>
<button
type="button"
onClick={closeCartDrawer}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
<div className="min-h-0 flex-1 p-4">
<CartContent />
</div>
{!isEmpty ? (
<div className="border-border shrink-0 border-t p-4">
<a
href={checkoutUrl ?? "#"}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 w-full items-center justify-center gap-2 px-4 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.cart.checkout}
</a>
</div>
) : null}
</div>
</dialog>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
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<Parameters<typeof useCart>[0]>[0];
type CartLine = CartState["data"]["lines"]["nodes"][number];
/**
* Shared cart line-item form — used by both the cart drawer and the `/cart`
* page (`hydrogen-cart-ui` / `hydrogen-cart-drawer`). Preserves the
* progressive-enhancement form contract: hidden `register("set")`, scoped
* `register("lineId", { value })`, and a real editable quantity input from
* `register("quantity", { value, interactive: true })`. Increase/decrease/remove
* are additional submit controls, not replacements.
*/
export function CartLineItem({ line }: { line: CartLine }) {
const { formProps, register } = useCartForm();
const pendingLines = useCart((state) => state.pending.lines);
const isPending = pendingLines.has(line.id);
const merchandise = line.merchandise;
const productTitle = merchandise?.product?.title ?? merchandise?.title ?? "Product";
const productHandle = merchandise?.product?.handle;
const selectedOptions = merchandise?.selectedOptions ?? [];
const variantSubtitle = selectedOptions.map((option) => option.value).join(" / ");
const totalAmount = line.cost.totalAmount;
const compareAt = line.cost.compareAtAmountPerQuantity ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(totalAmount.amount);
return (
<div
className="grid grid-cols-[var(--spacing-cart-line-thumbnail-width)_1fr_auto] items-stretch gap-3 py-4"
{...(isPending ? { "aria-busy": "true" } : {})}
>
<div className="bg-surface-secondary h-full w-full overflow-hidden">
{merchandise?.image ? (
<img
src={shopifyImageUrl(merchandise.image.url, { width: 128 })}
alt={merchandise.image.altText ?? `${productTitle} in ${variantSubtitle}`}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0">
{productHandle ? (
<a
href={`/products/${productHandle}`}
className="type-body-sm text-on-surface font-medium no-underline hover:opacity-70"
>
{productTitle}
</a>
) : (
<p className="type-body-sm text-on-surface font-medium">{productTitle}</p>
)}
{variantSubtitle ? (
<p className="text-on-surface-secondary mt-1 text-xs">{variantSubtitle}</p>
) : null}
<p className="text-on-surface mt-2 text-sm">
{onSale ? (
<span className="text-sale font-medium">{formatPrice(totalAmount)}</span>
) : (
formatPrice(totalAmount)
)}
{onSale && compareAt ? (
<>
{" "}
<s className="text-compare text-sm">{formatPrice(compareAt)}</s>
</>
) : null}
</p>
<form {...formProps()} className="mt-3">
<button {...register("set")} className="sr-only" tabIndex={-1} />
<input type="hidden" {...register("lineId", { value: line.id })} />
<div className="quantity-selector-outlined inline-flex items-center rounded">
<button
type="submit"
{...register("decrease")}
disabled={line.quantity <= 1}
className="button-icon h-8 w-9 rounded-s disabled:opacity-40"
aria-label={`Decrease quantity: ${productTitle}`}
>
–
</button>
<input
{...register("quantity", { value: line.quantity, interactive: true })}
className="number-reset h-8 w-12 text-center text-sm outline-none focus:outline-none"
aria-label={`Quantity: ${productTitle}`}
/>
<button
type="submit"
{...register("increase")}
className="button-icon h-8 w-9 rounded-e"
aria-label={`Increase quantity: ${productTitle}`}
>
+
</button>
</div>
</form>
</div>
<form {...formProps()}>
<input type="hidden" {...register("lineId", { value: line.id })} />
<button
type="submit"
{...register("remove")}
className="button-icon self-start rounded"
aria-label={`Remove: ${productTitle}`}
>
<img
src="/icons/icon-trash.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</form>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useCart } from "@/lib/cart";
import { CART_DRAWER_ID, openCartDrawer } from "@/lib/cart-drawer";
import { cartIconLabel, cartItemCount } from "@/lib/content";
/**
* Cart trigger — a `<button>` that opens the cart drawer via `showModal()`
* (`hydrogen-cart-drawer` skill). `onClick` calls `openCartDrawer()`, which
* calls `HTMLDialogElement.showModal()`; `aria-controls`/`aria-haspopup` keep
* the control accessible. The footer `/cart` link is the no-JS cart surface.
*/
export function CartTrigger() {
const totalQuantity = useCart((state) => state.data.totalQuantity);
const cartLabel = cartIconLabel(totalQuantity);
const countDisplay = totalQuantity > 99 ? "99+" : String(totalQuantity);
return (
<>
<button
type="button"
onClick={openCartDrawer}
aria-controls={CART_DRAWER_ID}
aria-haspopup="dialog"
className="text-on-surface focus-visible:outline-accent relative inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded bg-transparent p-0 hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97]"
aria-label={cartLabel}
>
<CartIcon count={totalQuantity} display={countDisplay} />
</button>
<span aria-live="polite" aria-atomic="true" className="sr-only">
{cartItemCount(totalQuantity)}
</span>
</>
);
}
function CartIcon({ count, display }: { count: number; display: string }) {
return (
<span
className="relative inline-flex size-5 shrink-0 items-center justify-center"
aria-hidden="true"
>
<img src="/icons/icon-cart.svg" width="20" height="20" alt="" className="size-5" />
{count > 0 ? (
<span className="bg-interactive text-interactive-text absolute end-0 top-0 flex h-5 w-5 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full text-xs font-medium">
{display}
</span>
) : null}
</span>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import { useCart } from "@/lib/cart";
/**
* Publishes `CART_VIEWED` when the `/cart` page is viewed
* (`hydrogen-analytics` / `references/react.md`). The cart payload is
* `AnalyticsCart | null`: when a compatible cart is available, include `id` +
* `updatedAt` + connection-shaped `lines`; otherwise pass `cart: null`.
*/
export function CartViewedTracker() {
const cart = useCart((state) => state.data);
useEffect(() => {
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.CART_VIEWED, {
cart: cart.id ? cart : null,
prevCart: null,
});
}, [cart]);
return null;
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import {
getFilterRemovalUrl,
getSortByValue,
serializeCollectionParams,
type AvailableFilter,
type ProductFilter,
} from "@shopify/hydrogen";
import { CollectionProvider, useCollection, useCollectionForm } from "@shopify/hydrogen/react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { ProductCard, type ProductCardData } from "@/components/ProductCard";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import { content } from "@/lib/content";
import { FilterGroup } from "@/lib/filters";
/**
* Shared collection/search browser (`hydrogen-collection-browser` /
* `references/nextjs.md`). The server page fetches the product + filter
* snapshot via `staticStorefrontClient` inside a `use cache` cache-point and
* passes it here. This client component owns the `CollectionProvider`,
* `useCollection`/`useCollectionForm`, URL sync via `useRouter`/`useSearchParams`,
* and the filter/sort/grid UI.
*
* `onChange` → `router.replace(href, { scroll: false })` + `router.refresh()` so
* the RSC payload catches up when the client URL is ahead (skill-mandated).
* Browse forms carry `method="get"` + explicit `action` (feedback Round 4 #4)
* so they submit without JS (F4).
*/
export const COLLECTION_SORT_OPTIONS = [
{ label: "Featured", value: getSortByValue("COLLECTION_DEFAULT", false) },
{ label: "Best selling", value: getSortByValue("BEST_SELLING", false) },
{ label: "Alphabetically, A-Z", value: getSortByValue("TITLE", false) },
{ label: "Alphabetically, Z-A", value: getSortByValue("TITLE", true) },
{ label: "Price, low to high", value: getSortByValue("PRICE", false) },
{ label: "Price, high to low", value: getSortByValue("PRICE", true) },
{ label: "Date, new to old", value: getSortByValue("CREATED", true) },
];
export const SEARCH_SORT_OPTIONS = [
{ label: "Relevance", value: getSortByValue("RELEVANCE", false) },
{ label: "Price, low to high", value: getSortByValue("PRICE", false) },
{ label: "Price, high to low", value: getSortByValue("PRICE", true) },
];
type PageInfo = { hasNextPage: boolean; endCursor?: string | null };
export type CollectionBrowserProps =
| {
mode: "collection";
handle: string;
collection: {
id: string;
handle: string;
title: string;
description?: string | null;
descriptionHtml?: string | null;
};
products: ProductCardData[];
availableFilters: AvailableFilter[];
pageInfo: PageInfo;
dataSearch: string;
}
| {
mode: "search";
term: string;
products: ProductCardData[];
availableFilters: AvailableFilter[];
pageInfo: PageInfo;
totalCount: number;
dataSearch: string;
};
export function CollectionBrowser(props: CollectionBrowserProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlSearch = searchParams.toString();
const providerHandle = props.mode === "collection" ? props.handle : `search:${props.term}`;
return (
<CollectionProvider
data={{ handle: providerHandle, dataSearch: props.dataSearch }}
urlSearch={urlSearch}
onChange={(search) => {
const href = `${pathname}${search}`;
if (urlSearch) router.replace(href, { scroll: false });
else router.push(href, { scroll: false });
router.refresh();
}}
>
{props.mode === "collection" ? (
<CollectionViewedTracker id={props.collection.id} handle={props.collection.handle} />
) : (
<SearchViewedTracker term={props.term} totalCount={props.totalCount} />
)}
{props.mode === "collection" ? <CollectionPage {...props} /> : <SearchPage {...props} />}
</CollectionProvider>
);
}
function CollectionViewedTracker({ id, handle }: { id: string; handle: string }) {
useEffect(() => {
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.COLLECTION_VIEWED, {
collection: { id, handle },
});
}, [id, handle]);
return null;
}
function SearchViewedTracker({ term, totalCount }: { term: string; totalCount: number }) {
useEffect(() => {
if (!term) return;
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.SEARCH_VIEWED, {
searchTerm: term,
searchResults: { totalCount },
});
}, [term, totalCount]);
return null;
}
type CollectionPageProps = Extract<CollectionBrowserProps, { mode: "collection" }>;
function CollectionPage(props: CollectionPageProps) {
const { collection, products, availableFilters, pageInfo } = props;
const state = useCollection();
const { formProps } = useCollectionForm();
const router = useRouter();
// Reset key for the uncontrolled filter subtree (checkboxes + price inputs).
// Keyed by the serialized filter state (NOT the URL) so the subtree remounts
// *after* the reconciler settles `state.filters` — clearing `defaultChecked` /
// `defaultValue` when an external navigation (chip removal, clear-all) empties
// the filters. Keying by the live URL is racy: the URL clears before
// `state.filters` settles, so the remount would bake in the stale checked
// state (hydrogen-collection-browser/references/nextjs.md reset-key guidance).
const filterSubtreeKey = serializeCollectionParams({
filters: state.filters,
sortKey: undefined,
reverse: false,
}).toString();
const isLoading = state.status === "loading";
const collectionPath = `/collections/${collection.handle}`;
// `<Link>` navigations (active-filter chips, clear-all, load-more) update the
// URL but, under Cache Components / PPR, the RSC payload doesn't re-fetch on a
// same-segment `searchParams` change unless we explicitly refresh. `onChange`
// (form-driven) already calls `router.refresh()`; this wires the same refresh
// to the `<Link>` clicks so the server products/filters catch up to the URL
// (hydrogen-collection-browser/references/nextjs.md).
const onNavigate = () => router.refresh();
const showingCount = content.collection.showingCount
.replace("{{ shown }}", String(products.length))
.replace("{{ total }}", String(products.length));
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs
items={[{ label: "Collections", href: "/collections" }, { label: collection.title }]}
/>
</div>
<h1 className="type-display mb-4">{collection.title}</h1>
{collection.descriptionHtml ? (
<div
className="richtext type-body text-on-surface-secondary mb-6 max-w-prose"
dangerouslySetInnerHTML={{ __html: collection.descriptionHtml }}
/>
) : collection.description ? (
<p className="type-body text-on-surface-secondary mb-6 max-w-prose">
{collection.description}
</p>
) : null}
<form
{...formProps()}
method="get"
action={collectionPath}
className="lg:grid lg:grid-cols-[240px_1fr] lg:gap-8"
>
<FilterSidebar
key={filterSubtreeKey}
availableFilters={availableFilters}
activeFilters={state.filters}
disabled={isLoading}
/>
<div key="results" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<p className="type-body-sm text-on-surface-secondary" aria-live="polite">
{showingCount}
</p>
<SortSelect isLoading={isLoading} options={COLLECTION_SORT_OPTIONS} />
</div>
<ActiveFilterChips
activeFilters={state.filters}
collectionPath={collectionPath}
onNavigate={onNavigate}
/>
{products.length === 0 ? (
<p className="text-on-surface-secondary py-12 text-center">
{content.collection.noProducts}
</p>
) : (
<ul
role="list"
className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-3"
>
{products.map((product, index) => (
<li key={product.id} className={isLoading ? "opacity-60" : ""}>
<ProductCard
product={product}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
)}
<LoadMore pageInfo={pageInfo} collectionPath={collectionPath} onNavigate={onNavigate} />
{/* No-JS submit so the GET filter/sort form is submittable without JS (F4). */}
<noscript>
<button type="submit" className="rounded-button button-primary h-11 px-4">
{content.collection.showResults}
</button>
</noscript>
</div>
</form>
</div>
);
}
type SearchPageProps = Extract<CollectionBrowserProps, { mode: "search" }>;
function SearchPage(props: SearchPageProps) {
const { term, products, availableFilters, pageInfo, totalCount } = props;
const state = useCollection();
const { formProps } = useCollectionForm();
const router = useRouter();
const onNavigate = () => router.refresh();
// Reset key for the uncontrolled filter subtree — see CollectionPage for rationale.
const filterSubtreeKey = serializeCollectionParams({
filters: state.filters,
sortKey: undefined,
reverse: false,
}).toString();
const isLoading = state.status === "loading";
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs items={[{ label: content.search.title }]} />
</div>
<h1 className="type-display mb-6">{content.search.title}</h1>
{/* Search header form — real GET /search so it works without JS (F4). */}
<form action="/search" method="get" role="search" className="mb-8 flex items-center gap-2">
<label htmlFor="search-q" className="sr-only">
{content.search.label}
</label>
<input
id="search-q"
type="search"
name="q"
defaultValue={term}
placeholder={content.search.placeholder}
className="number-reset rounded-button border-border h-11 max-w-md border px-3 text-sm"
/>
<button
type="submit"
className="rounded-button button-primary inline-flex h-11 items-center justify-center px-4 text-sm font-medium"
>
{content.search.submit}
</button>
{term ? (
<Link
href="/search"
className="text-on-surface-secondary hover:text-on-surface text-sm no-underline"
>
{content.search.clear}
</Link>
) : null}
</form>
{!term ? null : products.length === 0 ? (
<div className="py-12 text-center">
<p className="type-body text-on-surface">
{content.search.noResults.replace("{{ terms }}", `\u201c${term}\u201d`)}
</p>
<p className="text-on-surface-secondary mt-2 text-sm">
{content.search.noResultsSuggestion}
</p>
</div>
) : (
<form
{...formProps()}
method="get"
action="/search"
className="lg:grid lg:grid-cols-[240px_1fr] lg:gap-8"
key={`search-${term}`}
>
<input key="q" type="hidden" name="q" value={term} />
<aside key={filterSubtreeKey} className="hidden flex-col gap-6 lg:flex">
{availableFilters.map((filter) => (
<FilterGroup key={filter.id} filter={filter} activeFilters={state.filters} />
))}
</aside>
<div key="results" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<p className="type-body-sm text-on-surface-secondary" aria-live="polite">
{content.search.resultsFor
.replace("{{ count }}", String(totalCount))
.replace("{{ terms }}", `\u201c${term}\u201d`)}
</p>
<SortSelect isLoading={isLoading} options={SEARCH_SORT_OPTIONS} />
</div>
<ul
role="list"
className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-3"
>
{products.map((product, index) => (
<li key={product.id} className={isLoading ? "opacity-60" : ""}>
<ProductCard
product={product}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
{pageInfo.hasNextPage ? (
<div className="mt-8 text-center">
<Link
href={`/search?q=${encodeURIComponent(term)}&after=${encodeURIComponent(pageInfo.endCursor ?? "")}`}
onClick={onNavigate}
className="rounded-button button-outline inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline"
>
{content.search.loadMore}
</Link>
</div>
) : null}
<noscript>
<button type="submit" className="rounded-button button-primary h-11 px-4">
{content.collection.showResults}
</button>
</noscript>
</div>
</form>
)}
</div>
);
}
type SortOption = { label: string; value: string };
function SortSelect({ isLoading, options }: { isLoading: boolean; options: SortOption[] }) {
const state = useCollection();
const currentSort = useMemo(() => {
return serializeCollectionParams(state).toString();
}, [state]);
return (
<label className="flex items-center gap-2 text-sm">
<span className="text-on-surface-secondary">{content.collection.sortBy}</span>
<select
name="sort_by"
defaultValue={currentSort}
onChange={(event) => event.currentTarget.form?.requestSubmit()}
aria-busy={isLoading}
className="w-auto"
>
{options.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
function FilterSidebar({
availableFilters,
activeFilters,
disabled,
}: {
availableFilters: AvailableFilter[];
activeFilters: ProductFilter[];
disabled: boolean;
}) {
if (availableFilters.length === 0) return null;
return (
<aside className="hidden flex-col gap-6 lg:flex" aria-disabled={disabled}>
<div className="flex items-center justify-between">
<h2 className="type-heading-sm text-on-surface font-medium">
{content.collection.filters}
</h2>
</div>
{availableFilters.map((filter) => (
<FilterGroup key={filter.id} filter={filter} activeFilters={activeFilters} />
))}
</aside>
);
}
function ActiveFilterChips({
activeFilters,
collectionPath,
onNavigate,
}: {
activeFilters: ProductFilter[];
collectionPath: string;
onNavigate: () => void;
}) {
if (activeFilters.length === 0) return null;
return (
<ul role="list" className="flex flex-wrap gap-2">
{activeFilters.map((filter, index) => {
const currentParams = serializeCollectionParams({
filters: activeFilters,
sortKey: undefined,
reverse: false,
});
const removal = getFilterRemovalUrl(currentParams, filter);
const href = removal === "?" ? collectionPath : `${collectionPath}${removal}`;
return (
<li key={`${filter.toString()}-${index}`}>
<Link
href={href}
onClick={onNavigate}
className="chip-filled inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm no-underline"
>
{describeFilter(filter)}
<span aria-hidden="true">×</span>
</Link>
</li>
);
})}
<li>
<Link
href={collectionPath}
onClick={onNavigate}
className="text-accent inline-flex items-center rounded-full px-3 py-1 text-sm no-underline underline"
>
{content.collection.clearAll}
</Link>
</li>
</ul>
);
}
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";
}
function LoadMore({
pageInfo,
collectionPath,
onNavigate,
}: {
pageInfo: PageInfo;
collectionPath: string;
onNavigate: () => void;
}) {
if (!pageInfo.hasNextPage) return null;
const cursor = pageInfo.endCursor ?? "";
const href = `${collectionPath}?after=${encodeURIComponent(cursor)}`;
return (
<div className="mt-8 text-center">
<Link
href={href}
onClick={onNavigate}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.collection.loadMore}
</Link>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { StorefrontApi } from "@shopify/hydrogen";
import Link from "next/link";
import type { COLLECTION_CARD_QUERY } from "@/lib/fragments";
import { shopifyImageUrl, srcSetFor } from "@/lib/image";
/** The typed collection card node. */
export type CollectionCardData = NonNullable<
StorefrontApi.ResultOf<typeof COLLECTION_CARD_QUERY>["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` `<article>` 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. Server-rendered.
*/
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 (
<article className="group card relative overflow-hidden" aria-label={collection.title}>
<div className="bg-surface-secondary relative block aspect-square overflow-hidden motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.04]">
{image ? (
<img
src={shopifyImageUrl(image.url, { width: 600 })}
srcSet={srcSetFor(image.url, { width: 600 })}
alt={alt}
className="h-full w-full object-cover"
loading={loading}
{...(fetchPriority !== "auto" ? { fetchPriority: fetchPriority } : {})}
/>
) : null}
</div>
<div className="overlay-dark pointer-events-none absolute inset-0" />
<Link
href={`/collections/${collection.handle}`}
className="card-link"
aria-label={collection.title}
/>
<div className="text-interactive-text pointer-events-none absolute inset-x-0 bottom-0 z-10 p-4 text-left">
<h3 className="type-body-lg text-interactive-text">{collection.title}</h3>
</div>
</article>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
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<ConsentChoice | null>(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 (
<div className="consent-banner" role="region" aria-label={content.consent.label}>
<p className="type-body text-on-surface">
{content.consent.message}{" "}
<a href="#" className="text-accent underline">
{content.consent.privacyPolicy}
</a>
</p>
<div className="consent-banner-actions">
<button
type="button"
onClick={() => persist("declined")}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.consent.decline}
</button>
<button
type="button"
onClick={() => persist("accepted")}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.consent.acceptAll}
</button>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
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 (
<footer className="max-w-page px-margin mx-auto w-full">
<div className="border-border grid grid-cols-1 gap-8 border-t py-12 text-sm md:grid-cols-2 lg:grid-cols-4">
<div>
<h2 className="type-body-sm text-on-surface mb-4 font-medium">CORE</h2>
<p className="text-on-surface-secondary">&copy; 2026 CORE</p>
</div>
<nav aria-labelledby="footer-quick-links-heading">
<h2
className="type-body-sm text-on-surface mb-4 font-medium"
id="footer-quick-links-heading"
>
{content.footer.quickLinks}
</h2>
<ul role="list" className="flex flex-col gap-2">
<li>
<Link href="/collections" className={footerLinkClass}>
Collections
</Link>
</li>
<li>
<Link href="/collections/men" className={footerLinkClass}>
Men
</Link>
</li>
<li>
<Link href="/collections/women" className={footerLinkClass}>
Women
</Link>
</li>
<li>
<Link href="/collections/accessories" className={footerLinkClass}>
Accessories
</Link>
</li>
</ul>
</nav>
<nav aria-labelledby="footer-customer-care-heading">
<h2
className="type-body-sm text-on-surface mb-4 font-medium"
id="footer-customer-care-heading"
>
{content.footer.customerCare}
</h2>
<ul role="list" className="flex flex-col gap-2">
<li>
<Link href="/search" className={footerLinkClass}>
{content.footer.search}
</Link>
</li>
<li>
<Link href="/cart" className={footerLinkClass}>
{content.cart.title}
</Link>
</li>
</ul>
</nav>
<div>
<h2 className="type-body-sm text-on-surface mb-4 font-medium">
{content.footer.paymentMethods}
</h2>
<div className="flex flex-wrap items-center gap-2">
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Visa
</span>
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Mastercard
</span>
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Shop Pay
</span>
</div>
</div>
</div>
</footer>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { content } from "@/lib/content";
import { CartTrigger } from "./CartTrigger";
import { MobileNavDialog } from "./MobileNavDialog";
import { PredictiveSearchTrigger } from "./PredictiveSearchTrigger";
/** Maps a header nav item to its route. "Collections" -> the collections
* index; the category items -> their collection PLP. */
const navItemHref: Record<(typeof content.header.navItems)[number], string> = {
Collections: "/collections",
Men: "/collections/men",
Women: "/collections/women",
Accessories: "/collections/accessories",
};
/**
* Site header — server shell + small client islands (engineering.md F7;
* `hydrogen-setup` / `references/navbar.md`). Server-rendered: logo `<Link>`,
* desktop nav `<Link>`s, and a real `<Link href="/search">` search trigger
* (server-rendered, F4 — reachable without JS). Client islands: `CartTrigger`
* (`showModal()` button, cart count), `PredictiveSearchTrigger` (hydrates
* the `/search` link into the modal trigger), `MobileNavDialog` (`<dialog>` with
* an always-rendered fallback link list).
*
* The cart trigger opens the `<dialog>` drawer via `showModal()`
* (hydrogen-cart-drawer) — NO `hasHydrated` anchor/button swap (feedback
* Round 4 #3). The footer `/cart` link is the no-JS cart fallback.
*/
export function Header({ accountLink }: { accountLink?: ReactNode }) {
return (
<header className="border-border bg-surface sticky top-0 z-40 border-b">
<div
className="max-w-page px-margin mx-auto flex h-16 w-full items-center justify-between"
data-header-nav-group
>
<div className="flex items-center gap-2">
<MobileNavDialog />
<Link
href="/"
className="text-on-surface focus-visible:outline-accent inline-flex items-center rounded-sm text-lg font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
CORE
</Link>
</div>
<nav
aria-label={content.header.navigation}
className="mx-8 hidden min-w-0 flex-1 items-center gap-8 md:flex"
>
{content.header.navItems.map((item) => (
<Link
key={item}
href={navItemHref[item]}
className="text-on-surface focus-visible:outline-accent shrink-0 rounded-sm text-sm font-normal whitespace-nowrap no-underline hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-opacity"
>
{item}
</Link>
))}
</nav>
<div className="flex items-center gap-0">
<PredictiveSearchTrigger />
<noscript>
<form action="/search" method="get" role="search" className="sr-only">
<label htmlFor="header-search-q">{content.general.search}</label>
<input id="header-search-q" name="q" type="search" />
<button type="submit">{content.search.submit}</button>
</form>
</noscript>
{accountLink}
<CartTrigger />
</div>
</div>
</header>
);
}
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
import { isCustomerLoggedIn } from "@/lib/customer-account";
import { isCustomerAccountsAvailable } from "@/lib/storefront-config";
const accountLinkClassName =
"button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97] hover:opacity-70";
/**
* Header account link — an async Server Component resolved under `<Suspense>`
* by `AppShell`. Uses plain `<a>` (not `next/link`) for both `/account` and
* `/account/login`: `/account/login` is a Hydrogen-owned route intercepted in
* `proxy.ts` (no app route file), and a `next/link` client-side RSC navigation
* is fragile when the proxy responds to an RSC data fetch with a 303-to-
* external. A plain `<a>` forces a full-page navigation the proxy intercepts
* cleanly — the native choice for handler-intercepted paths.
*
* Gated on availability (not just `isLoggedIn`): on mock.shop the handlers
* aren't registered, so `/account/login` would 404. Hide the link entirely.
*/
export async function HeaderAccountLink() {
const available = isCustomerAccountsAvailable(); // sync — no await
if (!available) return null;
const loggedIn = await isCustomerLoggedIn();
if (loggedIn) {
return (
<a href="/account" className={accountLinkClassName} aria-label="Account">
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</a>
);
}
return (
<a href="/account/login" className={accountLinkClassName} aria-label="Log in">
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</a>
);
}
export function HeaderAccountLinkFallback() {
// Icon-sized placeholder (not the word "Account"): the resolved link renders
// a 20px user icon — or `null` on mock.shop — so showing text here would flash
// and swap to an icon. A dimmed icon matches the header's icon-button sizing.
return (
<span
role="status"
aria-live="polite"
aria-busy
aria-label="Loading account"
className="inline-flex h-11 w-11 items-center justify-center opacity-40"
>
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</span>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { content } from "@/lib/content";
const navItemHref: Record<(typeof content.header.navItems)[number], string> = {
Collections: "/collections",
Men: "/collections/men",
Women: "/collections/women",
Accessories: "/collections/accessories",
};
/**
* Mobile nav — a `<dialog>` opened via state, with an always-rendered fallback
* link list (F4). The hamburger button is the client trigger; the dialog
* contains the same nav items as the desktop nav. Server-rendered baseline is
* the hidden dialog content (links are real `<Link>`s).
*/
export function MobileNavDialog() {
const [open, setOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
return (
<div className="-ms-2 hidden max-md:block">
<button
type="button"
onClick={() => setOpen(true)}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.header.menu}
>
<img
src="/icons/icon-menu.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
<dialog
ref={dialogRef}
id="mobile-nav-drawer"
className="drawer-left bg-surface text-on-surface"
aria-labelledby="mobile-nav-title"
onClose={() => setOpen(false)}
>
<div className="flex h-full flex-col">
<div className="relative flex min-h-[52px] shrink-0 items-center px-4 py-1">
<span
className="text-on-surface pointer-events-none absolute left-1/2 -translate-x-1/2 text-sm font-medium"
id="mobile-nav-title"
aria-live="polite"
>
{content.header.mobileNavigation}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="button-icon focus-visible:outline-accent ms-auto inline-flex h-11 w-11 items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav aria-label={content.header.mobileNavigation}>
<ul role="list" className="flex flex-col">
{content.header.navItems.map((item) => (
<li key={item}>
<Link
href={navItemHref[item]}
onClick={() => setOpen(false)}
className="text-on-surface flex items-center rounded-sm py-3 text-xl font-normal no-underline hover:opacity-70 motion-safe:transition-opacity"
>
{item}
</Link>
</li>
))}
</ul>
</nav>
</div>
</div>
</dialog>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import {
getPredictiveSearchItemUrl,
type PredictiveSearchData,
type PredictiveSearchState,
} from "@shopify/hydrogen";
import {
PredictiveSearchProvider,
usePredictiveSearch,
usePredictiveSearchActions,
usePredictiveSearchForm,
} from "@shopify/hydrogen/react";
import Link from "next/link";
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";
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>`
* (`.dialog-center`). Products only, per `notes/predictive-search.md`.
*/
export function PredictiveSearchModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
return (
<PredictiveSearchProvider limit={PREDICTIVE_SEARCH_LIMIT} types={["PRODUCT"]}>
<PredictiveSearchDialogInner isOpen={isOpen} onClose={onClose} />
</PredictiveSearchProvider>
);
}
function PredictiveSearchDialogInner({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const state = usePredictiveSearch();
const { clear } = usePredictiveSearchActions();
const { formProps, register } = usePredictiveSearchForm();
// Open/close the native <dialog> 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 (
<dialog
ref={dialogRef}
id={PREDICTIVE_SEARCH_DIALOG_ID}
className="dialog-center"
aria-labelledby="search-modal-title"
onClose={onClose}
>
<div className="flex h-full flex-col">
<div className="border-border flex shrink-0 items-center gap-2 border-b p-4">
<form {...formProps()} role="search" className="flex flex-1 items-center gap-2">
<label htmlFor="predictive-search-q" className="sr-only">
{content.search.label}
</label>
<input
id="predictive-search-q"
type="search"
{...register("query")}
placeholder={content.search.placeholder}
className="flex-1"
autoComplete="off"
/>
<button type="submit" className="sr-only">
{content.search.submit}
</button>
</form>
<button
type="button"
onClick={onClose}
className="button-icon inline-flex h-11 w-11 items-center justify-center rounded"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
{state.result.term || state.result.items.products.length > 0 ? (
<div className="flex-1 overflow-y-auto p-4">
<h2 id="search-modal-title" className="sr-only">
{content.search.title}
</h2>
<PredictiveBody state={state} onNavigate={onClose} />
</div>
) : (
<h2 id="search-modal-title" className="sr-only">
{content.search.title}
</h2>
)}
{state.result.items.products.length > 0 ? (
<div className="border-border shrink-0 border-t p-4">
<Link
href={`/search?q=${encodeURIComponent(state.result.term)}`}
onClick={onClose}
className="rounded-button button-secondary inline-flex h-11 w-full items-center justify-center px-4 text-sm font-medium no-underline"
>
View all results
</Link>
</div>
) : null}
</div>
</dialog>
);
}
function PredictiveBody({
state,
onNavigate,
}: {
state: PredictiveSearchState<PredictiveSearchData>;
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 <p className="text-on-surface-secondary text-sm">Searching…</p>;
}
if (error) {
return (
<p role="alert" className="text-sale text-sm">
{error}
</p>
);
}
if (status === "success" && !hasResults && term) {
return (
<div data-testid="search-modal-empty">
<p className="text-on-surface text-sm">No results for “{term}”</p>
</div>
);
}
if (!hasResults) return null;
return (
<ul role="listbox" aria-label="Products" className="flex flex-col gap-2">
{products.map((product) => {
const href = getPredictiveSearchItemUrl(product, { routes: routeTemplates, term });
const variant = product.selectedOrFirstAvailableVariant;
const image = variant?.image ?? null;
const price = variant?.price ?? null;
return (
<li key={product.id} role="option" aria-selected={false}>
<Link
href={href}
onClick={onNavigate}
className="hover:bg-hover flex items-center gap-3 rounded p-2 no-underline"
>
{image ? (
<img
src={shopifyImageUrl(image.url, { width: 64 })}
alt={image.altText ?? product.title}
className="size-12 rounded object-cover"
/>
) : null}
<span className="flex flex-col">
<span className="type-body-sm text-on-surface font-medium">{product.title}</span>
{price ? (
<span className="text-on-surface-secondary text-sm">{formatPrice(price)}</span>
) : null}
</span>
</Link>
</li>
);
})}
</ul>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { content } from "@/lib/content";
import { PredictiveSearchModal } from "./PredictiveSearchModal";
/**
* Search trigger — a real server-rendered `<Link href="/search">` baseline
* (F4: reachable without JS) that hydrates into the predictive-search modal
* trigger after hydration. Renders `PredictiveSearchModal` (client) when open.
*/
export function PredictiveSearchTrigger() {
const [searchOpen, setSearchOpen] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => setHasHydrated(true), []);
if (!hasHydrated) {
// No-JS / pre-hydration baseline: a real /search link.
return (
<Link
href="/search"
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.search}
data-testid="search-modal-trigger"
>
<img
src="/icons/icon-search.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</Link>
);
}
return (
<>
<button
type="button"
onClick={() => setSearchOpen(true)}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.search}
data-testid="search-modal-trigger"
>
<img
src="/icons/icon-search.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
<PredictiveSearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { StorefrontApi } from "@shopify/hydrogen";
import Link from "next/link";
import type { 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<typeof PRODUCT_CARD_QUERY>["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` +
* `<s class="text-compare">` 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. Server-rendered (no client
* interactivity).
*/
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 (
<article className="group card flex flex-col gap-2" aria-label={product.title}>
<div className="rounded-card relative block aspect-square overflow-hidden">
{primaryImage ? (
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.04]">
<img
src={shopifyImageUrl(primaryImage.url, { width: 600 })}
srcSet={srcSetFor(primaryImage.url, { width: 600 })}
alt={primaryImage.altText ?? product.title}
className="h-full w-full object-cover"
loading={loading}
{...(fetchPriority !== "auto" ? { fetchPriority: fetchPriority } : {})}
/>
</div>
) : (
<div className="bg-surface-secondary h-full w-full" />
)}
{secondaryImage ? (
<div className="pointer-events-none absolute inset-0 opacity-0 motion-safe:transition-opacity motion-safe:duration-300 motion-safe:group-hover:opacity-100">
<img
src={shopifyImageUrl(secondaryImage.url, { width: 600 })}
srcSet={srcSetFor(secondaryImage.url, { width: 600 })}
alt={secondaryImage.altText ?? `${product.title} alternate`}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : null}
{onSale ? (
<span className="badge-sale absolute start-2 top-2 inline-flex items-center rounded-full font-medium">
Sale
</span>
) : null}
{soldOut ? (
<span className="badge-soldout absolute start-2 top-2 inline-flex items-center rounded-full font-medium">
Sold out
</span>
) : null}
</div>
<div className="flex flex-col gap-0.5 text-left">
<h3 className="type-body-sm text-on-surface line-clamp-2 font-medium">
<Link href={`/products/${product.handle}`} className="card-link text-on-surface">
{product.title}
</Link>
</h3>
<div className="inline-flex flex-wrap items-baseline gap-2 text-sm">
<span className={onSale ? "text-sale font-medium" : "text-on-surface font-medium"}>
<span className="sr-only">{onSale ? "Sale price: " : "Price: "}</span>
{formatPrice(minPrice)}
</span>
{onSale && compareAt ? (
<s className="text-compare text-sm">
<span className="sr-only">Regular price: </span>
{formatPrice(compareAt)}
</s>
) : null}
</div>
</div>
</article>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { canAddToCart, type SelectedOption } from "@shopify/hydrogen";
import { ShopPayButton } from "@shopify/hydrogen/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ProductViewedTracker } from "@/components/ProductViewedTracker";
import { QuantityStepper } from "@/components/QuantityStepper";
import { openCartDrawer } from "@/lib/cart-drawer";
import { content } from "@/lib/content";
import { shopifyImageUrl, srcSetFor } from "@/lib/image";
import { formatPrice } from "@/lib/money";
import { ProductProvider, useProductForm } from "@/lib/product";
import type { ProductData } from "@/lib/product-query";
import { canonicalUrl, jsonLdScript } from "@/lib/site";
/**
* Interactive product details (`hydrogen-variant-form` /
* `references/nextjs.md`). Wraps the server-fetched product in `ProductProvider`;
* variant selection + add-to-cart live here (client). Same-product option values
* are GET `<Link>`s with `aria-current` (no-JS degrades to server variant
* resolution); cross-product values navigate to the other product; non-existent
* combinations are disabled `<button>`s with `aria-pressed`.
*/
export function ProductDetails({ product }: { product: ProductData }) {
const router = useRouter();
return (
<ProductProvider
product={product}
onSelect={(result) => {
const targetHandle = result.selectedVariant?.product?.handle ?? product.handle;
const next = variantUrl(
product,
result.selectedOptions,
targetHandle,
new URLSearchParams(),
);
router.replace(next, { scroll: false });
}}
>
<ProductViewedTracker product={product} />
<ProductPage product={product} />
</ProductProvider>
);
}
function ProductPage({ product }: { product: ProductData }) {
const { options, selectedVariant, formProps, register, errors } = useProductForm();
const searchParams = useSearchParams();
const addable = canAddToCart(product, options);
const addToCartProps = register("addToCart", {});
const price = selectedVariant?.price ?? product.priceRange.minVariantPrice;
const compareAt = selectedVariant?.compareAtPrice ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(price.amount);
const allGalleryImages = product.media.nodes
.map((node) => (node.__typename === "MediaImage" && node.image ? node.image : null))
.filter((image): image is NonNullable<typeof image> => image !== null);
// Reorder so the selected variant's image is first (feedback Round 3 #4 /
// Round 4 gallery). If the variant image isn't in the media set, prepend it.
const variantImage = selectedVariant?.image ?? null;
const galleryImages = (() => {
if (!variantImage) return allGalleryImages;
const matchIndex = allGalleryImages.findIndex((image) => image.url === variantImage.url);
if (matchIndex <= 0) {
return matchIndex === 0 ? allGalleryImages : [variantImage, ...allGalleryImages];
}
return [
allGalleryImages[matchIndex],
...allGalleryImages.slice(0, matchIndex),
...allGalleryImages.slice(matchIndex + 1),
];
})();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.title,
description: product.description ?? undefined,
image: galleryImages.map((image) => image.url),
offers: selectedVariant
? {
"@type": "Offer",
price: selectedVariant.price.amount,
priceCurrency: selectedVariant.price.currencyCode,
availability: selectedVariant.availableForSale
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
url: canonicalUrl(`/products/${product.handle}`),
}
: {
"@type": "AggregateOffer",
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
lowPrice: product.priceRange.minVariantPrice.amount,
highPrice: product.priceRange.maxVariantPrice.amount,
},
};
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd) }}
/>
<div className="product-grid mb-16 grid grid-cols-1 gap-6 md:gap-12">
{/* Gallery */}
<div className="grid grid-cols-2 gap-2">
{galleryImages.map((image, index) => (
<div key={image.url} className="bg-surface-secondary aspect-square overflow-hidden">
<img
src={shopifyImageUrl(image.url, { width: index === 0 ? 800 : 400 })}
srcSet={srcSetFor(image.url, { width: index === 0 ? 800 : 400 })}
alt={image.altText ?? product.title}
className="h-full w-full object-cover"
loading={index === 0 ? "eager" : "lazy"}
{...(index === 0 ? { fetchPriority: "high" } : {})}
/>
</div>
))}
</div>
{/* Info column */}
<div className="flex flex-col gap-4 md:sticky md:top-8 md:self-start">
<h1 className="type-display">{product.title}</h1>
{product.vendor ? (
<p className="text-on-surface-secondary text-sm">{product.vendor}</p>
) : null}
<div className="inline-flex flex-wrap items-baseline gap-2 text-lg">
<span className={onSale ? "text-sale font-medium" : "text-on-surface font-medium"}>
{formatPrice(price)}
</span>
{onSale && compareAt ? (
<s className="text-compare text-base">{formatPrice(compareAt)}</s>
) : null}
</div>
{selectedVariant && !selectedVariant.availableForSale ? (
<p className="text-on-surface-secondary text-sm">{content.product.soldOut}</p>
) : null}
{/* Variant options */}
<div className="flex flex-col gap-4">
{options.map((option) => (
<fieldset key={option.name} className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface font-medium">{option.name}</legend>
<div className="flex flex-wrap gap-2">
{option.values.map((value) =>
value.handle && value.handle !== product.handle ? (
// Cross-product value — navigates to the other product
// (hydrogen-variant-form combined-listings rule). Uses the
// live `useSearchParams` base so unrelated params survive.
<Link
key={value.name}
href={variantUrl(
product,
value.selectedOptions,
value.handle,
searchParams,
)}
scroll={false}
className="option-pill no-underline"
>
{value.name}
</Link>
) : value.exists ? (
// Same-product value — a real GET `<Link>` to the option
// URL so selection works without JS (the server page
// resolves the variant). Hydration enhances the same
// element via `register("optionValue", ...)`; the
// provider `onSelect` syncs the URL client-side.
// `aria-current` marks the selected link (`aria-pressed`
// is invalid on a link).
<Link
key={value.name}
href={variantUrl(
product,
value.selectedOptions,
value.handle,
searchParams,
)}
replace
scroll={false}
aria-current={value.selected ? "true" : undefined}
className="option-pill no-underline"
{...register("optionValue", {
optionName: option.name,
value: value.name,
})}
>
{value.name}
</Link>
) : (
// Non-existent combination — no valid option URL to
// degrade to, so render a disabled `<button>` with
// `aria-pressed` (hydrogen-variant-form).
<button
key={value.name}
type="button"
aria-pressed={value.selected}
disabled
className="option-pill"
>
{value.name}
</button>
),
)}
</div>
</fieldset>
))}
</div>
{/* Add to cart form — separate from variant selection (variant-form skill).
`formProps({ afterSubmit: openCartDrawer })` opens the drawer once
on a successful reply; do not double-wire it on the button. */}
<form {...formProps({ afterSubmit: openCartDrawer })} className="flex flex-col gap-3">
<input type="hidden" {...register("merchandiseId", {})} />
<div className="flex items-center gap-3">
<span className="type-body-sm text-on-surface font-medium">
{content.product.quantity}
</span>
<QuantityStepper
inputProps={register("quantity", { defaultValue: 1 })}
label={content.product.quantity}
/>
</div>
<button
{...addToCartProps}
disabled={!addable}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97]"
>
{addable
? content.product.addToCart
: selectedVariant
? content.product.soldOut
: content.product.selectOptions}
</button>
</form>
{selectedVariant ? (
<ShopPayButton
variants={[{ id: selectedVariant.id, quantity: 1 }]}
channel="hydrogen"
disabled={!addable}
width="100%"
borderRadius="0.5rem"
/>
) : null}
{errors.userErrors.length > 0 ? (
<ul role="alert" className="text-sale text-sm">
{errors.userErrors.map((error, index) => (
<li key={index}>{error.message}</li>
))}
</ul>
) : null}
{product.descriptionHtml ? (
<div className="richtext type-body mt-4">
<h2 className="type-heading-md mb-2">{content.product.description}</h2>
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
</div>
) : product.description ? (
<div className="type-body mt-4">
<h2 className="type-heading-md mb-2">{content.product.description}</h2>
<p>{product.description}</p>
</div>
) : null}
</div>
</div>
</div>
);
}
/**
* Build a product variant URL from a handle + selected options, preserving the
* live `useSearchParams` (or any `URLSearchParams`) base so unrelated params
* like `q` or UTM aren't dropped on a cross/combined-listing link
* (`hydrogen-variant-form` / `references/nextjs.md`).
*/
function variantUrl(
product: { handle: string; options: Array<{ name: string }> },
selectedOptions: SelectedOption[],
handle = product.handle,
base: URLSearchParams | ReturnType<typeof useSearchParams> = new URLSearchParams(),
): string {
const params = new URLSearchParams(base);
for (const option of product.options) params.delete(option.name);
for (const option of selectedOptions) params.set(option.name, option.value);
const query = params.toString();
return `/products/${handle}${query ? `?${query}` : ""}`;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef } from "react";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import type { ProductData } from "@/lib/product-query";
/**
* Publishes `PRODUCT_VIEWED` when the product route mounts/changes
* (`hydrogen-analytics` / `references/react.md`). Rendered inside the
* `ProductProvider` so it re-runs on product identity changes.
*/
export function ProductViewedTracker({ product }: { product: ProductData }) {
const publishedProductHandleRef = useRef<string | undefined>(undefined);
const variant = product.selectedOrFirstAvailableVariant;
const productHandle = product.handle;
const productId = product.id;
const productTitle = product.title;
const productVendor = product.vendor;
const productPrice = variant?.price.amount ?? product.priceRange.minVariantPrice.amount;
const variantId = variant?.id ?? productId;
const variantTitle = variant?.title ?? productTitle;
const variantSku = variant?.sku ?? undefined;
useEffect(() => {
if (publishedProductHandleRef.current === productHandle) return;
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.PRODUCT_VIEWED, {
products: [
{
id: productId,
title: productTitle,
price: productPrice,
vendor: productVendor,
variantId,
variantTitle,
quantity: 1,
sku: variantSku,
},
],
});
// Track route identity separately from payload deps so exhaustive-deps stays honest without duplicate publishes.
publishedProductHandleRef.current = productHandle;
}, [
productHandle,
productId,
productPrice,
productTitle,
productVendor,
variantId,
variantSku,
variantTitle,
]);
return null;
}
+78
View File
@@ -0,0 +1,78 @@
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 `<input>` 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` + a reduced height (`h-9`) and a wider
* number field (`w-12`) so the digits aren't cramped relative to the buttons.
*/
type QuantityStepperProps<T extends React.InputHTMLAttributes<HTMLInputElement>> = {
/** Props spread onto the number `<input>` (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;
};
export function QuantityStepper<T extends React.InputHTMLAttributes<HTMLInputElement>>({
inputProps,
label,
min = 1,
step = 1,
className = "",
}: QuantityStepperProps<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const nudge = (delta: number) => {
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 (
<div
className={`quantity-selector-outlined inline-flex items-center rounded ${className}`}
role="group"
aria-label={label}
>
<button
type="button"
onClick={() => nudge(-1)}
className="button-icon h-8 w-9 rounded-s"
aria-label={`Decrease ${label}`}
>
–
</button>
<input
ref={inputRef}
type="number"
{...inputProps}
className="number-reset h-8 w-12 text-center text-sm outline-none focus:outline-none"
aria-label={label}
/>
<button
type="button"
onClick={() => nudge(1)}
className="button-icon h-8 w-9 rounded-e"
aria-label={`Increase ${label}`}
>
+
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { defaultI18n, type shop as shopConfigType } from "@shared/config";
import { ShopifyScripts } from "@shopify/hydrogen/react";
import { useRouter } from "next/navigation";
import { routeTemplates } from "@/lib/route-templates";
/**
* `ShopifyScripts` client wrapper (`hydrogen-analytics` / `references/react.md`
* + `hydrogen-setup` / `references/analytics.md`). The root layout is a server
* component and cannot call `useRouter`, so `ShopifyScripts` (which needs a
* `navigate` callback) must live in a client component. Rendered once in the
* root layout with the resolved market `i18n` (single-market example →
* `defaultI18n`) and the numeric `shop` from `@shared/config`.
*/
type ShopConfig = typeof shopConfigType;
export function ShopifyScriptsClient({ shop }: { shop: ShopConfig }) {
const router = useRouter();
return (
<ShopifyScripts
i18n={defaultI18n}
shop={shop}
navigate={(url: string) => router.push(url)}
routes={routeTemplates}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
import "server-only";
import { analyticsShop as analyticsShopConfig, shop as shopConfig } from "@shared/config";
import { cacheLife, cacheTag } from "next/cache";
import { SHOP_ANALYTICS_QUERY } from "@/lib/queries";
import { staticStorefrontClient } from "@/lib/storefront-static";
/**
* Resolve the shop analytics GID best-effort + non-blocking (engineering.md F1).
* The query runs inside a `'use cache'` cache-point (`cacheLife("hours")`,
* `cacheTag("shop")`) so warm requests resolve instantly. On timeout/error we
* fall back to the config-derived shop GID/name (avoids drift — `@shared/config`
* `analyticsShop.shopId` is already `gid://shopify/Shop/${shop.shopId}`).
*/
export type AnalyticsShop = {
shopId: string;
shopName: string;
shopDescription: string | null;
};
const SHOP_FALLBACK: AnalyticsShop = {
shopId: analyticsShopConfig.shopId,
shopName: "CORE",
shopDescription: null,
};
/** Cache the shop query result for hours (it almost never changes). */
async function fetchShopAnalytics(): Promise<AnalyticsShop> {
"use cache";
cacheLife("hours");
cacheTag("shop");
const { data, errors } = await staticStorefrontClient.graphql(SHOP_ANALYTICS_QUERY);
if (errors) {
console.error("[hydrogen] Root shop query failed", errors);
}
return {
shopId: data?.shop?.id ?? SHOP_FALLBACK.shopId,
shopName: data?.shop?.name ?? SHOP_FALLBACK.shopName,
shopDescription: data?.shop?.description ?? null,
};
}
/**
* Best-effort, non-blocking shop analytics resolution. Races the cached query
* against a 2000ms timeout; on timeout/error falls back to `@shared/config`.
* Merges the resolved GID/name with the config-derived `analyticsShop`
* metadata (acceptedLanguage/currency/hydrogenSubchannelId).
*/
export async function getAnalyticsShop(): Promise<{
shopId: string;
acceptedLanguage: string;
currency: string;
hydrogenSubchannelId: string;
shopName: string;
shopDescription: string | null;
}> {
let resolved = SHOP_FALLBACK;
try {
resolved = await Promise.race([fetchShopAnalytics(), timeoutReject<AnalyticsShop>(2000)]);
} catch (error) {
console.error("[hydrogen] Root shop query failed or timed out", error);
}
return {
shopId: resolved.shopId,
acceptedLanguage: analyticsShopConfig.acceptedLanguage,
currency: analyticsShopConfig.currency,
hydrogenSubchannelId: analyticsShopConfig.hydrogenSubchannelId,
shopName: resolved.shopName,
shopDescription: resolved.shopDescription,
};
}
/** Merge with the config-derived shop for `ShopifyScripts` (numeric shopId). */
export function getScriptsShop() {
return shopConfig;
}
function timeoutReject<T>(ms: number): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`shop query timed out after ${ms}ms`)), ms);
});
}
+44
View File
@@ -0,0 +1,44 @@
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) {
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;
}
+60
View File
@@ -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 (`<dialog>` + `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();
+43
View File
@@ -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,
});
+11
View File
@@ -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<typeof cartHandlers>();
+148
View File
@@ -0,0 +1,148 @@
/**
* Copy strings transcribed from `examples/core/content.json`. Centralized so
* the React Router 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));
}
+53
View File
@@ -0,0 +1,53 @@
import "server-only";
import { customerAccountConfig, defaultI18n } from "@shared/config";
import { EncryptedCookieCustomerSession } from "@shared/customer-session";
import { createShopifyRequestContext } from "@shopify/hydrogen";
import { createCustomerSession } from "@shopify/hydrogen/customer-account";
import { headers } from "next/headers";
import { isCustomerAccountsAvailable } from "./storefront-config";
const DEFAULT_REQUEST_ORIGIN = "https://example.com";
export const customerSession = createCustomerSession({
shopId: customerAccountConfig.shopId,
customerAccountApiClientId: customerAccountConfig.customerAccountApiClientId,
});
export async function createCustomerSessionManager(request: Request) {
return EncryptedCookieCustomerSession.init(request, customerAccountConfig.sessionSecret);
}
// Reconstruct a Request from next/headers (App Router doesn't hand you one).
export async function createCurrentRequest(pathname = "/account") {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
if (!host) {
return new Request(`${DEFAULT_REQUEST_ORIGIN}${pathname}`, { headers: requestHeaders });
}
const protocol = requestHeaders.get("x-forwarded-proto") ?? "https";
return new Request(`${protocol}://${host}${pathname}`, { headers: requestHeaders });
}
export async function isCustomerLoggedIn() {
if (!isCustomerAccountsAvailable()) return false;
const { requestContext, sessionManager } = await createCustomerRequestContext();
return customerSession.isLoggedIn(sessionManager, requestContext);
}
export async function getCustomerAccessToken() {
const { requestContext, sessionManager } = await createCustomerRequestContext();
return {
accessToken: await customerSession.getAccessToken(sessionManager, requestContext),
requestContext,
};
}
async function createCustomerRequestContext(pathname = "/account") {
const request = await createCurrentRequest(pathname);
return {
request,
requestContext: createShopifyRequestContext({ request, i18n: defaultI18n }),
sessionManager: await createCustomerSessionManager(request),
};
}
+9
View File
@@ -0,0 +1,9 @@
import { createCustomerAccountServerHandlers } from "@shopify/hydrogen/customer-account";
import { customerSession } from "./customer-account";
export const customerSessionHandlers = createCustomerAccountServerHandlers({
customerSession,
defaultPostLoginRedirectPathname: "/account",
postLogoutRedirectUri: "/",
});
+150
View File
@@ -0,0 +1,150 @@
import {
isFilterInputActive,
serializeCollectionParams,
type AvailableFilter,
type ProductFilter,
} from "@shopify/hydrogen";
import { content } from "./content";
/**
* Shared collection/search filter 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.
*/
/** 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 (
<label className="min-h-touch-target flex items-center gap-2 text-sm">
<input
type="checkbox"
name={name}
value={paramValue}
defaultChecked={isFilterInputActive(activeFilters, value.input)}
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="size-4"
/>
<span className="text-on-surface">{value.label}</span>
{value.count > 0 ? (
<span className="text-on-surface-secondary text-xs">({value.count})</span>
) : null}
</label>
);
}
/** 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 (
<fieldset className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface mb-1 font-medium">{filter.label}</legend>
<div className="flex items-center gap-2">
<label className="flex flex-1 items-center gap-1 text-sm">
<span className="text-on-surface-secondary sr-only">{content.collection.priceMin}</span>
<input
type="number"
name="filter.v.price.gte"
min={0}
defaultValue={min}
placeholder={content.collection.priceMin}
inputMode="numeric"
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="number-reset rounded-button border-border h-9 w-full border px-2 text-sm"
/>
</label>
<span className="text-on-surface-secondary text-sm">{content.collection.priceTo}</span>
<label className="flex flex-1 items-center gap-1 text-sm">
<span className="text-on-surface-secondary sr-only">{content.collection.priceMax}</span>
<input
type="number"
name="filter.v.price.lte"
min={0}
defaultValue={max}
placeholder={content.collection.priceMax}
inputMode="numeric"
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="number-reset rounded-button border-border h-9 w-full border px-2 text-sm"
/>
</label>
</div>
</fieldset>
);
}
/** 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 <PriceRangeFilter filter={filter} activeFilters={activeFilters} />;
}
return (
<fieldset className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface mb-1 font-medium">{filter.label}</legend>
{filter.values.map((value) => (
<FilterValueInput
key={value.id}
filter={filter}
value={value}
activeFilters={activeFilters}
/>
))}
</fieldset>
);
}
+125
View File
@@ -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
}
`);
+61
View File
@@ -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 `<img>`. 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`;
}
+42
View File
@@ -0,0 +1,42 @@
import { defaultI18n } from "@shared/config";
import type { I18nConfig } from "@shopify/hydrogen";
/**
* Markets resolver (`hydrogen-markets` / `references/nextjs.md` host-based
* shape). Reads the forwarded storefront URL (`x-storefront-url`, set by
* `proxy.ts` via `requestContext.getForwardedRequestHeaders()`) and resolves a
* market from a `MARKET_BY_HOST` allowlist, defaulting to the shared config's
* `defaultI18n` (US/EN).
*
* This is a single-market example, so the allowlist is empty (everything falls
* back to the default) — but the helper is wired so multi-market is an
* extension, not a rewrite. `Market` is `I18nConfig` so the resolved value is
* directly assignable to `createShopifyRequestContext({ i18n })`.
*/
export type Market = I18nConfig;
export const DEFAULT_MARKET: Market = defaultI18n;
const MARKET_BY_HOST: Record<string, Market> = {
// Single-market example: only the default. Add host -> market mappings here
// when the storefront goes multi-market (values must be valid
// ShopifyCountryCode / ShopifyLanguageCode pairs).
};
export function getMarketFromHeaders(headers: Pick<Headers, "get">): Market {
const forwardedUrl = headers.get("x-storefront-url");
if (!forwardedUrl) return DEFAULT_MARKET;
try {
const { hostname } = new URL(forwardedUrl);
const host = hostname.toLowerCase();
return MARKET_BY_HOST[host] ?? DEFAULT_MARKET;
} catch {
return DEFAULT_MARKET;
}
}
/** The static client uses the default market (no `headers()` access). */
export function getDefaultMarket(): Market {
return DEFAULT_MARKET;
}
+18
View File
@@ -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();
}
+11
View File
@@ -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"],
});
+111
View File
@@ -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<StorefrontApi.ResultOf<typeof PRODUCT_QUERY>["product"]>;
+10
View File
@@ -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<ProductData>();
+47
View File
@@ -0,0 +1,47 @@
import { defaultI18n, storefrontConfig } from "@shared/config";
import { createShopifyRequestContext, createStorefrontClient } from "@shopify/hydrogen";
/**
* Browser-safe public Storefront client (`hydrogen-storefront-client` /
* `references/nextjs.md` "Public client"). Holds only the **public** Storefront
* API access token, which Shopify throttles per client IP, so each shopper gets
* their own bucket. Safe to import from Client Components — it carries no
* private token and no request-scoped state.
*
* Use it for interactive client-side Storefront fetches from Client Components
* (e.g. TanStack Query / SWR): predictive autocomplete, "load more" pagination,
* availability polling. Example:
*
* // in a "use client" component
* useQuery({
* queryKey: ["search", term],
* queryFn: () => publicStorefrontClient.graphql(SEARCH, { variables: { term } }),
* });
*
* The current example routes browser predictive search through the same-origin
* `/api/predictive-search` handler (registered in `proxy.ts`) rather than
* calling Storefront directly, so this client is provided as the sanctioned
* pattern for future client-side GraphQL — not dead code. For server-side
* (RSC/route-handler) fetches, use `getStorefrontClient()` (per-buyer) or
* `staticStorefrontClient` (shared rate limit) from `lib/storefront.ts` /
* `lib/storefront-static.ts` instead.
*
* NB: `@shared/config` only inlines public values (store domain + public token)
* and a local-dev session-secret placeholder; it is safe to bundle. Production
* apps should source the public token from a `NEXT_PUBLIC_*` env var.
*/
const requestContext = createShopifyRequestContext({
// Static request context — no `headers()`, no buyer IP. The public client is
// per-IP-throttled by Shopify, not per-buyer.
request: { headers: new Headers() },
i18n: defaultI18n,
});
export const publicStorefrontClient = createStorefrontClient({
type: "public",
requestContext,
config: {
storeDomain: storefrontConfig.storeDomain,
publicStorefrontToken: storefrontConfig.publicStorefrontToken ?? "",
},
});
+159
View File
@@ -0,0 +1,159 @@
import { gql } from "@shopify/hydrogen";
import { COLLECTION_CARD_FRAGMENT, PRODUCT_CARD_FRAGMENT } from "./fragments";
/**
* Home query — best-selling products (first 8) + featured collections (first 3).
* Used by the home page (`hydrogen-setup` / `references/home-page.md`).
*/
export const HOME_QUERY = gql(
`
query Home($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
featuredProducts: products(first: 8, sortKey: BEST_SELLING) {
nodes {
...ProductCard
}
}
featuredCollections: collections(first: 3) {
nodes {
...CollectionCard
}
}
}
`,
[PRODUCT_CARD_FRAGMENT, COLLECTION_CARD_FRAGMENT],
);
/** Collections index query (`collections(first: 24)`). */
export const COLLECTIONS_QUERY = gql(
`
query CollectionsList($first: Int!, $after: String, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
...CollectionCard
}
}
}
`,
[COLLECTION_CARD_FRAGMENT],
);
/**
* Collection PLP query. `filters` and `sortKey`/`reverse` come from
* `parseCollectionParams`. The `__typename` discipline is on `ProductCard`
* (no unions here).
*/
export const COLLECTION_QUERY = gql(
`
query Collection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collection(handle: $handle) {
id
handle
title
description
descriptionHtml
image {
url
altText
width
height
}
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
filters {
id
label
type
values {
id
label
count
input
}
}
pageInfo {
hasNextPage
endCursor
}
nodes {
...ProductCard
}
}
}
}
`,
[PRODUCT_CARD_FRAGMENT],
);
/**
* Search query. **`__typename` on `search.nodes` is required** — `search` is a
* heterogeneous union and without `__typename` gql.tada infers `never` for the
* node type and all results are dropped (feedback Round 1 + Round 2 #1).
*/
export const SEARCH_QUERY = gql(
`
query Search($query: String!, $first: Int!, $after: String, $sortKey: SearchSortKeys, $reverse: Boolean, $productFilters: [ProductFilter!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
search(query: $query, first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, productFilters: $productFilters) {
productFilters {
id
label
type
values {
id
label
count
input
}
}
pageInfo {
hasNextPage
endCursor
}
nodes {
__typename
... on Product {
...ProductCard
}
}
}
}
`,
[PRODUCT_CARD_FRAGMENT],
);
/** Shop analytics GID query (root layout, best-effort + non-blocking, F1). */
export const SHOP_ANALYTICS_QUERY = gql(`
query RootShopAnalytics {
shop {
id
name
description
}
}
`);
/** Sitemap query — all products + collections with `updatedAt`. */
export const SITEMAP_QUERY = gql(`
query Sitemap($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
products(first: 250) {
nodes {
handle
updatedAt
}
}
collections(first: 250) {
nodes {
handle
updatedAt
}
}
}
`);
+7
View File
@@ -0,0 +1,7 @@
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 changes to custom product, collection, page, blog, or article paths.
export const routeTemplates = createShopifyRouteTemplates({
productInCollection: "/products/:productHandle",
});
+27
View File
@@ -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
* (Next.js dev port 3000) so the example works without extra config.
*/
export const SITE_ORIGIN =
typeof process !== "undefined"
? (process.env.PUBLIC_SITE_ORIGIN ?? "http://localhost:3000")
: "http://localhost:3000";
/** 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 `<script type="application/ld+json">`
* tag (engineering.md F6). This is an app-owned helper — it is NOT a Hydrogen
* export. It escapes `<` and `</script>` 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(/</g, "\\u003c").replace(/<\/script>/gi, "\\u003c/script\\u003e");
return escaped;
}
+63
View File
@@ -0,0 +1,63 @@
import "server-only";
import { storefrontConfig } from "@shared/config";
import { getOptionalSharedSecret } from "@shared/private-env";
/**
* Shared Storefront config resolver with mock.shop fallback
* (`hydrogen-storefront-client` + the example's zero-secrets contract).
*
* Used by both client factories (`storefront.ts` for the per-buyer cart-seed
* client, `storefront-static.ts` for the shared-rate-limit catalog client) and
* by `proxy.ts` so the request handlers hit the same store as the RSC data
* path.
*
* When no `PRIVATE_STOREFRONT_API_TOKEN` is provisioned (local dev without the
* decrypted ejson secrets), the client falls back to the public mock.shop
* endpoint using its well-known `mock-private-token` so the example runs with
* zero secrets. With a real private token present, the configured store is used
* unchanged (an optional `PUBLIC_STORE_DOMAIN` env override lets local dev
* point at a specific store without re-deriving the shared config).
*/
export const MOCK_SHOP_DOMAIN = "mock.shop";
export const MOCK_SHOP_PRIVATE_TOKEN = "mock-private-token";
export type ResolvedStorefrontConfig = {
storeDomain: string;
privateStorefrontToken: string;
};
let mockShopFallbackWarned = false;
/** Whether Customer Accounts are enabled for the resolved storefront.
*
* Sync on purpose: `resolveStorefrontConfig` is sync, and keeping this sync
* removes the risk of a forgotten `await` producing a `Promise<boolean>`
* that's always truthy when spread into `handlers` (which would silently
* register the customer account handlers on mock.shop). Poka-yoke. */
export function isCustomerAccountsAvailable(): boolean {
const { storeDomain } = resolveStorefrontConfig();
return storeDomain !== MOCK_SHOP_DOMAIN;
}
export function resolveStorefrontConfig(): ResolvedStorefrontConfig {
const privateStorefrontToken = getOptionalSharedSecret("PRIVATE_STOREFRONT_API_TOKEN");
if (!privateStorefrontToken) {
if (!mockShopFallbackWarned) {
mockShopFallbackWarned = true;
console.warn(
`[hydrogen-example-nextjs] No PRIVATE_STOREFRONT_API_TOKEN found — ` +
`running against mock.shop (${MOCK_SHOP_DOMAIN}). Set ` +
`PRIVATE_STOREFRONT_API_TOKEN in .env.local to hit a real store.`,
);
}
return {
storeDomain: MOCK_SHOP_DOMAIN,
privateStorefrontToken: MOCK_SHOP_PRIVATE_TOKEN,
};
}
const storeDomain = process.env.PUBLIC_STORE_DOMAIN ?? storefrontConfig.storeDomain;
return { storeDomain, privateStorefrontToken };
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { createShopifyRequestContext, createStorefrontClient } from "@shopify/hydrogen";
import { DEFAULT_MARKET } from "./markets";
import { resolveStorefrontConfig } from "./storefront-config";
/**
* Shared-rate-limit private Storefront client for **all catalog reads**
* (`hydrogen-storefront-client` / `references/nextjs.md` static-pages shape +
* engineering.md F2). Module-scoped: one client for the process, no `headers()`
* → no buyer IP, shared throttle bucket. Single-market example → `DEFAULT_MARKET`.
*
* Catalog pages (home, collections index, collection PLP, product, search,
* sitemap, related products, shop analytics GID) fetch through this client.
* Only the cart seed uses the per-buyer `getStorefrontClient()`.
*
* Caching lives at the `use cache` boundary (cache-points keyed by serializable
* inputs). The `cache:` option is never passed to `graphql()` — Next native
* data cache + `cacheLife`/`cacheTag` replace the Oxygen sub-request LRU.
*/
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: DEFAULT_MARKET,
});
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
export const staticStorefrontClient = createStorefrontClient({
type: "private_no_buyer_context",
requestContext,
config: {
storeDomain,
privateStorefrontToken,
},
});
+46
View File
@@ -0,0 +1,46 @@
import "server-only";
import { getBuyerIp } from "@shared/buyer-ip";
import {
createShopifyRequestContext,
createStorefrontClient,
type RequestScopedPrivateStorefrontClient,
} from "@shopify/hydrogen";
import { headers } from "next/headers";
import { cache } from "react";
import { getMarketFromHeaders } from "./markets";
import { resolveStorefrontConfig } from "./storefront-config";
/**
* Per-buyer private Storefront client (`hydrogen-storefront-client` /
* `references/nextjs.md` dynamic-pages shape). Created inside `cache(async
* () => …)` so it is request-scoped and deduped within one RSC request. Reads
* `headers()` → dynamic render + per-buyer buyer IP + market.
*
* **Used only for the cart seed in the root layout** (skill-mandated; the cart
* is personalized). Catalog reads go through `staticStorefrontClient`
* (`storefront-static.ts`) so they share a throttle bucket and never carry a
* buyer IP (F2).
*/
export const getStorefrontClient = cache(
async (): Promise<RequestScopedPrivateStorefrontClient<{}>> => {
const requestHeaders = await headers();
const requestContext = createShopifyRequestContext({
request: { headers: requestHeaders },
i18n: getMarketFromHeaders(requestHeaders),
});
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
const buyerIp = getBuyerIp(requestHeaders);
return createStorefrontClient({
type: "private",
requestContext,
config: {
storeDomain,
privateStorefrontToken,
buyerIp,
},
});
},
);
+20
View File
@@ -0,0 +1,20 @@
/**
* Adapt a Next.js App Router `searchParams` record (`Record<string, string |
* string[] | undefined>`) into a `URLSearchParams` for Hydrogen helpers that
* expect a `URLSearchParams` (`getSelectedProductOptions`, `parseCollectionParams`).
*
* `searchParams` is a `Promise<Record<...>>` in Next 15+ — `await` it first.
*/
export function toURLSearchParams(
input: Record<string, string | string[] | undefined>,
): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(input)) {
if (Array.isArray(value)) {
for (const item of value) params.append(key, item);
} else if (value != null) {
params.set(key, value);
}
}
return params;
}
+25
View File
@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
/**
* Next.js 16 config for the Hydrogen example.
*
* `cacheComponents: true` enables Cache Components / `use cache` (engineering.md
* F2 native Next caching instead of the Oxygen sub-request LRU).
*
* No `next/image` remote patterns — we use plain `<img>` + the `hydrogen-image`
* helper (F12; Hydrogen ships no Image component and we size CDN URLs ourselves).
*/
const nextConfig: NextConfig = {
cacheComponents: true,
// React Strict Mode is disabled because `@shopify/hydrogen/react`'s
// `PredictiveSearchProvider` destroys its store in the Strict Mode effect
// cleanup (double-invoke), and `useMemo` returns the same (now-destroyed)
// store instance for the second mount — so the predictive search store is
// permanently `destroyed` and `store.search()` no-ops, breaking header
// autocomplete. Disabling Strict Mode avoids the destroy; the cart provider
// is unaffected (it recreates its store). Re-enable once the provider is
// Strict-Mode-safe upstream.
reactStrictMode: false,
};
export default nextConfig;
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@shopify/hydrogen-example-nextjs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"https:dev": "next dev --experimental-https --port 5173",
"build": "next build --debug-prerender",
"start": "next start",
"typecheck": "tsc && gql.tada check --fail-on-warn"
},
"dependencies": {
"@shopify/hydrogen": "0.0.0-preview-8a708a8-20260708155454",
"isbot": "^5",
"next": "16.2.9",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"gql.tada": "^1.9.2",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.0"
},
"packageManager": "yarn@4.14.1+sha512.64df448055b2d37ba269d7db535a469b8da93f8ef1140c25fd7a83c00a8fbaacb214ca0e02553b92a2c54cef78bb67d0b4817fab02001df0e24fac0faccc3b42"
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+91
View File
@@ -0,0 +1,91 @@
import { getBuyerIp } from "@shared/buyer-ip";
import { defaultI18n } from "@shared/config";
import {
createShopifyRequestContext,
createStorefrontClient,
handleShopifyRoutes,
} from "@shopify/hydrogen";
import { NextResponse, type NextRequest } from "next/server";
import { cartHandlers } from "@/lib/cart-handlers";
import { createCustomerSessionManager } from "@/lib/customer-account";
import { customerSessionHandlers } from "@/lib/customer-session-handlers";
import { predictiveSearchHandlers } from "@/lib/predictive-search-handlers";
import { MOCK_SHOP_DOMAIN, resolveStorefrontConfig } from "@/lib/storefront-config";
/**
* Next.js request lifecycle (`hydrogen-request-handlers` /
* `references/nextjs.md`). `proxy.ts` runs `handleShopifyRoutes` before framework
* routing — Hydrogen-owned routes (`/api/cart`, `/api/predictive-search`,
* `/api/{ver}/graphql.json`, `/admin`, …) short-circuit here. Storefront URL
* redirects run in `app/not-found.tsx` (post-404), never here.
*
* The original request URL is forwarded to Server Components via
* `requestContext.getForwardedRequestHeaders()` (carries `x-storefront-url` for
* `not-found.tsx` and `getMarketFromHeaders`). SFAPI response headers are merged
* onto the forwarded response via `requestContext.applyResponseHeaders`.
*
* mock.shop fallback: when no `PRIVATE_STOREFRONT_API_TOKEN` is present, the
* shared `resolveStorefrontConfig()` falls back to `mock.shop` + its well-known
* `mock-private-token` so the example runs with zero secrets.
*/
export async function proxy(request: NextRequest) {
const requestContext = createShopifyRequestContext({
request,
i18n: defaultI18n,
});
const buyerIp = getBuyerIp(request.headers);
const sessionManager = await createCustomerSessionManager(request);
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
const storefrontClient = createStorefrontClient({
type: "private",
requestContext,
config: {
storeDomain,
privateStorefrontToken,
buyerIp,
},
});
// Customer Accounts are only available on a real store (mock.shop has no
// Customer Account API). Inline the resolved `storeDomain` check rather than
// calling `isCustomerAccountsAvailable()` to avoid a second config read.
// Sync — no `await` — so the handlers can never be a stray `Promise<boolean>`
// that's always truthy when spread.
const customerAccountsAvailable = storeDomain !== MOCK_SHOP_DOMAIN;
const handlers = [
cartHandlers,
predictiveSearchHandlers,
...(customerAccountsAvailable ? [customerSessionHandlers] : []),
];
const shopifyRoute = await handleShopifyRoutes({
request,
requestContext,
sessionManager,
storefrontClient,
handlers,
});
if (shopifyRoute) {
// Hydrogen-owned route (cart/predictive-search/SFAPI proxy/admin).
// `handleShopifyRoutes` already applies SFAPI response headers onto the
// short-circuited response internally, so no `applyResponseHeaders` here.
return shopifyRoute;
}
// Forward the original URL (via `x-storefront-url`) + request context headers
// to Server Components, then merge SFAPI response headers onto the response.
const requestHeaders = requestContext.getForwardedRequestHeaders();
const response = NextResponse.next({ request: { headers: requestHeaders } });
requestContext.applyResponseHeaders(response.headers);
return response;
}
export const config = {
// Exclude static Next assets; keep Hydrogen-owned paths (e.g. /admin,
// /api/cart, /api/{ver}/graphql.json) reachable.
matcher: ["/((?!_next/static|_next/image|_next/data|favicon.ico).*)"],
};
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="76" height="81" fill="none" viewBox="0 0 76 81">
<path fill="#000" d="M37.817 80.149 0 60.057l12.934-6.817 14.561 7.733 12.218-6.441-14.561-7.733 12.933-6.833 37.818 20.091-12.934 6.817-13.757-7.307-12.236 6.457 13.775 7.308-12.934 6.817Z"/>
<path fill="url(#a)" d="M37.818 40.183 0 20.092l12.934-6.818 14.562 7.733 12.218-6.441-14.562-7.733L38.086 0l37.817 20.091-12.934 6.817-13.756-7.307-12.236 6.457 13.774 7.308-12.933 6.817Z"/>
<defs>
<linearGradient id="a" x1="54.285" x2="42.84" y1="0" y2="46.16" gradientUnits="userSpaceOnUse">
<stop stop-color="#0DA3D3"/>
<stop offset="1" stop-color="#27245D"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 714 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="var(--icon-stroke-width)" d="M3.392 6.875h13.216v8.016c0 .567-.224 1.112-.624 1.513-.4.402-.941.627-1.506.627H5.522a2.13 2.13 0 0 1-1.506-.627 2.15 2.15 0 0 1-.624-1.513zM8.818 2.969h2.333c.618 0 1.211.247 1.649.686a2.35 2.35 0 0 1 .683 1.658v1.562H6.486V5.313c0-.622.246-1.218.683-1.658a2.33 2.33 0 0 1 1.65-.686"/></svg>

After

Width:  |  Height:  |  Size: 484 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 11L1.5 6L6.5 1" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1L6.5 6L1.5 11" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"><path d="M16.823 6h-12m10 4h-8m2 4h4"/></svg>

After

Width:  |  Height:  |  Size: 203 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 3.5H13" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"/>
<path d="M1 10.5H13" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.75 7H11.25" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="vertical" d="M2.75 7H11.25" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"/>
<path class="horizontal" d="M7 2.75L7 11.25" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none"><circle cx="9" cy="9" r="4.75" stroke="currentColor" stroke-width="var(--icon-stroke-width)"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="var(--icon-stroke-width)" d="m12.5 12.5 3.25 3.25"/></svg>

After

Width:  |  Height:  |  Size: 314 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round" stroke-linejoin="round">
<g class="remove-icon-top">
<polyline points="2.5 5 4.17 5 17.5 5"/>
<path d="M7.5 5V3.33a.83.83 0 0 1 .83-.83h3.34a.83.83 0 0 1 .83.83V5"/>
</g>
<g class="remove-icon-bottom">
<path d="M15.83 5l-.83 11.67a1.67 1.67 0 0 1-1.67 1.66H6.67a1.67 1.67 0 0 1-1.67-1.66L4.17 5"/>
<path d="M8.33 9.17v5M11.67 9.17v5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 529 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="5.83" r="3.33" stroke="currentColor" stroke-width="var(--icon-stroke-width)"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="var(--icon-stroke-width)" d="M16.67 17.5v-1.67a3.33 3.33 0 0 0-3.33-3.33H6.67a3.33 3.33 0 0 0-3.33 3.33V17.5"/></svg>

After

Width:  |  Height:  |  Size: 377 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="var(--icon-stroke-width)" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>

After

Width:  |  Height:  |  Size: 224 B

+15
View File
@@ -0,0 +1,15 @@
const FIRST_FORWARDED_IP_INDEX = 0;
export const BUYER_IP_HEADERS = ["oxygen-buyer-ip", "cf-connecting-ip", "x-forwarded-for"] as const;
export const DEVELOPMENT_BUYER_IP = "127.0.0.1";
export function getBuyerIp(headers: Pick<Headers, "get">): string {
for (const header of BUYER_IP_HEADERS) {
const buyerIp = headers.get(header)?.split(",")[FIRST_FORWARDED_IP_INDEX]?.trim();
if (buyerIp) return buyerIp;
}
if (process.env.NODE_ENV !== "production") return DEVELOPMENT_BUYER_IP;
throw new Error(`${BUYER_IP_HEADERS.join(", ")} is required for private Storefront API clients`);
}
+93
View File
@@ -0,0 +1,93 @@
type StorefrontConfigShape = {
storeDomain: string;
publicStorefrontToken?: string;
privateStorefrontTokenEnvKey: PrivateStorefrontTokenEnvKey;
};
type I18nShape = { country: "US"; language: "EN" };
type PrivateStorefrontTokenEnvKey =
| "PRIVATE_STOREFRONT_API_TOKEN_HYDROGEN_PREVIEW"
| "PRIVATE_STOREFRONT_API_TOKEN_OXYGEN_COOKIE";
type ShopifyScriptsShopShape = {
shopId: string;
storefrontId: string;
};
type AnalyticsShopShape = {
shopId: string;
acceptedLanguage: string;
currency: string;
hydrogenSubchannelId: string;
};
type AnalyticsConsentShape = {
consentDomain?: string;
publicStorefrontAccessToken?: string;
mode: "default-banner" | "custom-banner" | "no-banner";
};
export type CustomerAccountConfigShape = {
shopId: string;
customerAccountApiClientId: string;
sessionSecret: string;
};
// Local examples only. Production apps must use a private, per-app secret or server-side sessions.
const LOCAL_CUSTOMER_SESSION_SECRET = "local-dev-customer-session-secret-32plus";
// Use this for consent testing
const _oxygenCookiesStore = {
storeDomain: "oxygencookies.myshopify.com",
publicStorefrontToken: "8eece95833df895900c1b285987c7f40",
privateStorefrontTokenEnvKey: "PRIVATE_STOREFRONT_API_TOKEN_OXYGEN_COOKIE",
publicCustomerAccountApiClientId: "1156385c-3f73-484c-95cd-8900737d3fcf",
publicStorefrontId: "1000070242",
shopId: "56819351626",
} as const;
const hydrogenPreviewStore = {
storeDomain: "hydrogen-preview.myshopify.com",
publicStorefrontToken: "b97a750a8afa8fe33f2b4012cb3a9f6f",
privateStorefrontTokenEnvKey: "PRIVATE_STOREFRONT_API_TOKEN_HYDROGEN_PREVIEW",
publicCustomerAccountApiClientId: "shp_e2b55f4e-9dd6-48aa-91f8-5fa419fda119",
publicStorefrontId: "1000014875",
shopId: "55145660472",
} as const;
const ACTIVE_STORE = hydrogenPreviewStore;
export const storefrontConfig = {
storeDomain: ACTIVE_STORE.storeDomain,
publicStorefrontToken: ACTIVE_STORE.publicStorefrontToken,
privateStorefrontTokenEnvKey: ACTIVE_STORE.privateStorefrontTokenEnvKey,
} satisfies StorefrontConfigShape;
export const customerAccountConfig = {
shopId: ACTIVE_STORE.shopId,
customerAccountApiClientId: ACTIVE_STORE.publicCustomerAccountApiClientId,
sessionSecret: LOCAL_CUSTOMER_SESSION_SECRET,
} satisfies CustomerAccountConfigShape;
export const defaultI18n = {
country: "US",
language: "EN",
} satisfies I18nShape;
export const shop = {
shopId: ACTIVE_STORE.shopId,
storefrontId: ACTIVE_STORE.publicStorefrontId,
} satisfies ShopifyScriptsShopShape;
export const analyticsShop = {
shopId: `gid://shopify/Shop/${ACTIVE_STORE.shopId}`,
acceptedLanguage: "EN",
currency: "USD",
hydrogenSubchannelId: ACTIVE_STORE.publicStorefrontId,
} satisfies AnalyticsShopShape;
export const analyticsConsent = {
mode: "default-banner",
publicStorefrontAccessToken: ACTIVE_STORE.publicStorefrontToken,
} satisfies AnalyticsConsentShape;
+190
View File
@@ -0,0 +1,190 @@
const COOKIE_NAME = "__Host-hydrogen_customer_session";
const COOKIE_PATH = "/";
const COOKIE_VERSION = "v1";
const COOKIE_SEPARATOR = ".";
const AES_GCM_ALGORITHM = "AES-GCM";
const AES_GCM_IV_LENGTH_IN_BYTES = 12;
const SECRET_MIN_LENGTH = 32;
const MAX_COOKIE_LENGTH_IN_BYTES = 4_096;
const BYTE_CHUNK_SIZE = 32_768;
const EXPIRED_COOKIE_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
type SessionRecord = Record<string, unknown>;
/**
* Portable encrypted cookie session for these examples. Production apps should
* prefer opaque server-side session storage when their framework offers it.
*/
export class EncryptedCookieCustomerSession {
#data: SessionRecord;
#origin: string;
#secret: string;
#isDirty = false;
private constructor(data: SessionRecord, origin: string, secret: string) {
this.#data = data;
this.#origin = origin;
this.#secret = secret;
}
static async init(request: Request, secret: string) {
assertSessionSecret(secret);
const cookieValue = getCookieValue(request.headers.get("cookie"), COOKIE_NAME);
const data = cookieValue ? await decryptSessionCookie(cookieValue, secret) : {};
return new EncryptedCookieCustomerSession(data, new URL(request.url).origin, secret);
}
getSessionItem(key: string) {
return this.#data[key];
}
getSessionOrigin() {
return this.#origin;
}
setSessionItem(key: string, value: unknown) {
this.#data[key] = value;
this.#isDirty = true;
}
removeSessionItem(key: string) {
delete this.#data[key];
this.#isDirty = true;
}
async commit() {
if (!this.#isDirty) return;
const headers = new Headers();
const cookie = hasSessionData(this.#data)
? await serializeSessionCookie(await encryptSessionCookie(this.#data, this.#secret))
: serializeExpiredSessionCookie();
assertCookieFitsBrowserLimit(cookie);
headers.set("Set-Cookie", cookie);
this.#isDirty = false;
return headers;
}
}
async function encryptSessionCookie(data: SessionRecord, secret: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH_IN_BYTES));
const key = await deriveKey(secret);
const plaintext = new TextEncoder().encode(JSON.stringify(data));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: AES_GCM_ALGORITHM, iv }, key, plaintext),
);
return [COOKIE_VERSION, base64UrlEncodeBytes(iv), base64UrlEncodeBytes(ciphertext)].join(
COOKIE_SEPARATOR,
);
}
async function decryptSessionCookie(value: string, secret: string): Promise<SessionRecord> {
try {
const [version, iv, ciphertext] = value.split(COOKIE_SEPARATOR);
if (version !== COOKIE_VERSION || !iv || !ciphertext) return {};
const ivBytes = base64UrlDecodeBytes(iv);
const ciphertextBytes = base64UrlDecodeBytes(ciphertext);
const decrypted = await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv: toArrayBuffer(ivBytes) },
await deriveKey(secret),
toArrayBuffer(ciphertextBytes),
);
const parsed = JSON.parse(new TextDecoder().decode(decrypted));
return isSessionRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
async function deriveKey(secret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
return crypto.subtle.importKey("raw", digest, AES_GCM_ALGORITHM, false, ["encrypt", "decrypt"]);
}
async function serializeSessionCookie(value: string): Promise<string> {
return serializeCookie(COOKIE_NAME, value, [
`Path=${COOKIE_PATH}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
]);
}
function serializeExpiredSessionCookie(): string {
return serializeCookie(COOKIE_NAME, "", [
`Path=${COOKIE_PATH}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
"Max-Age=0",
`Expires=${EXPIRED_COOKIE_DATE}`,
]);
}
function serializeCookie(name: string, value: string, attributes: string[]) {
return [`${name}=${value}`, ...attributes].join("; ");
}
function assertCookieFitsBrowserLimit(cookie: string) {
if (new TextEncoder().encode(cookie).byteLength <= MAX_COOKIE_LENGTH_IN_BYTES) return;
throw new Error(
`Customer Account session cookie exceeds ${MAX_COOKIE_LENGTH_IN_BYTES} bytes. Use opaque server-side session storage for production account sessions.`,
);
}
function getCookieValue(cookieHeader: string | null, name: string): string | undefined {
if (!cookieHeader) return undefined;
for (const cookie of cookieHeader.split(";")) {
const [cookieName, ...valueParts] = cookie.trim().split("=");
if (cookieName === name) return valueParts.join("=");
}
}
function base64UrlEncodeBytes(bytes: Uint8Array): string {
const binary = bytesToBinary(bytes);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function base64UrlDecodeBytes(value: string): Uint8Array {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const paddingLength = (4 - (base64.length % 4)) % 4;
const binary = atob(base64.padEnd(base64.length + paddingLength, "="));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function bytesToBinary(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += BYTE_CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.slice(index, index + BYTE_CHUNK_SIZE));
}
return binary;
}
function hasSessionData(data: SessionRecord) {
return Object.keys(data).length > 0;
}
function isSessionRecord(value: unknown): value is SessionRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertSessionSecret(secret: string) {
if (secret.length < SECRET_MIN_LENGTH) {
throw new Error(`SESSION_SECRET must be at least ${SECRET_MIN_LENGTH} characters long.`);
}
}
+40
View File
@@ -0,0 +1,40 @@
const PRIVATE_STOREFRONT_TOKEN_KEY = "PRIVATE_STOREFRONT_API_TOKEN";
type RuntimeEnv = Record<string, string | undefined>;
export function getPrivateStorefrontToken(env?: object | null): string {
return getSharedSecret(PRIVATE_STOREFRONT_TOKEN_KEY, env);
}
export function getSharedSecret(key: string, env?: object | null): string {
const value = getOptionalSharedSecret(key, env);
if (!value) {
throw new Error(
`${key} is required. ` +
`Set it in .env.local or in the environment.`,
);
}
return value;
}
export function getOptionalSharedSecret(key: string, env?: object | null): string | undefined {
const value = getRuntimeEnv(env)[key];
return isPresentString(value) ? value : undefined;
}
function getRuntimeEnv(env?: object | null): RuntimeEnv {
const globalEnv = (
globalThis as {
process?: { env?: RuntimeEnv };
}
).process?.env;
const metaEnv = (import.meta as ImportMeta & { env?: RuntimeEnv }).env;
const explicitEnv = env as RuntimeEnv | undefined;
return { ...metaEnv, ...globalEnv, ...explicitEnv };
}
function isPresentString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
+51
View File
@@ -0,0 +1,51 @@
{
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"compilerOptions": {
"plugins": [
{
"name": "next"
},
{
"name": "gql.tada/ts-plugin",
"schemas": [
{
"name": "storefront",
"schema": "node_modules/@shopify/hydrogen/dist/storefront.schema.json",
"tadaOutputLocation": "./storefront-graphql-env.d.ts"
},
{
"name": "customer-account",
"schema": "node_modules/@shopify/hydrogen/dist/customer-account.schema.json",
"tadaOutputLocation": "./customer-account-graphql-env.d.ts"
}
],
"trackFieldUsage": false
}
],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"paths": {
"@shared/*": ["./shared/*"],
"@/*": ["./*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"allowJs": true,
"incremental": true
},
"exclude": ["node_modules"]
}
+1200
View File
File diff suppressed because it is too large Load Diff