Initial commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">© 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>();
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createCustomerAccountServerHandlers } from "@shopify/hydrogen/customer-account";
|
||||
|
||||
import { customerSession } from "./customer-account";
|
||||
|
||||
export const customerSessionHandlers = createCustomerAccountServerHandlers({
|
||||
customerSession,
|
||||
defaultPostLoginRedirectPathname: "/account",
|
||||
postLogoutRedirectUri: "/",
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`);
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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"]>;
|
||||
@@ -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>();
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||