Initial commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
Rami Bitar
2026-07-29 11:02:40 -04:00
co-authored by Claude Fable 5
commit f23332e42c
88 changed files with 7947 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
import "server-only";
import { customerAccountConfig } from "@shared/config";
import { createCustomerAccountClient, gql } from "@shopify/hydrogen/customer-account";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getCustomerAccessToken } from "@/lib/customer-account";
import { isCustomerAccountsAvailable } from "@/lib/storefront-config";
import { toURLSearchParams } from "@/lib/url-params";
export const metadata: Metadata = {
title: "Account",
};
const CUSTOMER_QUERY = gql(`
query CurrentCustomer {
customer {
firstName
lastName
emailAddress {
emailAddress
}
}
}
`);
type AccountCustomer = {
firstName?: string | null;
lastName?: string | null;
emailAddress?: { emailAddress?: string | null } | null;
};
type AccountPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
/**
* `/account` — core Customer Accounts surface (name + email only).
*
* No `export const dynamic` / `export const fetchCache`: under
* `cacheComponents: true`, `force-dynamic` is not allowed. The page relies on
* the `AppShell` dynamic context (`await connection()` in the root layout's
* shell), which opts the whole subtree into dynamic rendering — any
* `headers()`/`cookies()` read inside (e.g. `getCustomerAccessToken()` →
* `createCurrentRequest()` → `headers()`) is then automatically dynamic, the
* same convention `cart/page.tsx` follows.
*
* Token refresh is delegated to the `/account/refresh` handler (intercepted in
* `proxy.ts`): the page only **reads** the token via `getAccessToken`
* (read-only, no mutation, no render-time commit — the proxy runs before RSC
* render and cannot commit mutations that occur during render). When no usable
* token is present, the page `redirect()`s to `/account/refresh?return_to=…`;
* the handler refreshes, commits the cookie on its response, and redirects
* back to `/account`, which now reads a valid token.
*/
export default async function AccountPage({ searchParams }: AccountPageProps) {
const available = isCustomerAccountsAvailable(); // sync — no await
const urlSearchParams = toURLSearchParams(await searchParams);
const loginFailed = urlSearchParams.get("login") === "failed";
const refreshAttempted = urlSearchParams.get("refreshed") === "1";
if (!available) return <AccountShell notice="real-store" />;
const { accessToken, requestContext } = await getCustomerAccessToken();
if (!accessToken && !loginFailed && !refreshAttempted) {
// Delegate the refresh to the `/account/refresh` handler instead of calling
// `getOrRefreshAccessToken` here: this page only *reads* the token during
// RSC render, and the proxy runs before render — it cannot commit a cookie
// mutation that happens during render. Refreshing in-page would break that
// no-render-commit invariant. The handler refreshes, sets the cookie on its
// own response, and redirects back with `refreshed=1`.
redirect(`/account/refresh?return_to=${encodeURIComponent("/account?refreshed=1")}`);
}
if (!accessToken) return <AccountShell loginFailed={loginFailed} />;
// Build the client per-call (Next has no RR "context" equivalent; per-call
// construction from `headers()` is the native Next approach, matching
// `getStorefrontClient`).
const customerAccount = createCustomerAccountClient({
shopId: customerAccountConfig.shopId,
requestContext,
});
// Keep the try/catch so a network/timeout failure surfaces as a friendly
// error card instead of an unhandled 500 in RSC render.
let customer: AccountCustomer | undefined;
let error: string | undefined;
try {
const { data, errors } = await customerAccount.graphql(CUSTOMER_QUERY, { accessToken });
customer = errors ? undefined : data.customer;
error = errors?.[0]?.message;
} catch {
error = "Customer Account API request failed. Try again later.";
}
return <AccountShell customer={customer} error={error} />;
}
type AccountShellProps =
| { notice: "real-store" }
| { loginFailed: boolean }
| { customer?: AccountCustomer | null; error?: string };
function AccountShell(props: AccountShellProps) {
return (
<div className="max-w-page px-margin mx-auto w-full py-16" aria-labelledby="account-heading">
<h1 id="account-heading" className="type-display mb-4">
Account
</h1>
<p className="type-body-lg text-on-surface-secondary max-w-xl">
Sign in with Shopify Customer Accounts to view your basic account identity.
</p>
{"notice" in props && <RealStoreNotice />}
{"loginFailed" in props && <LoginPanel loginFailed={props.loginFailed} />}
{"customer" in props &&
(props.error ? (
<CustomerAccountError message={props.error} />
) : (
<CustomerCard customer={props.customer} />
))}
</div>
);
}
function RealStoreNotice() {
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="notice-heading"
>
<h2 id="notice-heading" className="type-heading-lg mb-3">
Customer Accounts require a real store
</h2>
<p className="type-body text-on-surface-secondary max-w-xl">
This example is running against <code>mock.shop</code>, which has no Customer Account API.
Set your store credentials in <code>.env.local</code> and run{" "}
<code>yarn https:dev</code> to enable login.
</p>
</section>
);
}
function LoginPanel({ loginFailed }: { loginFailed: boolean }) {
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="login-heading"
>
<h2 id="login-heading" className="type-heading-lg mb-3">
Sign in
</h2>
<p className="type-body text-on-surface-secondary mb-6 max-w-xl">
Use your customer account to view your name and email for this store.
</p>
{loginFailed ? (
<p
tabIndex={-1}
role="alert"
autoFocus
className="border-border bg-surface-secondary text-on-surface focus-visible:outline-accent mb-6 rounded border p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
We could not complete your login. Try signing in again.
</p>
) : null}
{/* Plain `<a>` — `/account/login` is handler-intercepted in `proxy.ts`. */}
<a
href="/account/login"
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Log in
</a>
</section>
);
}
function CustomerAccountError({ message }: { message: string }) {
return (
<p
role="alert"
className="border-border bg-surface-secondary text-on-surface mt-8 rounded border p-4"
>
{message}
</p>
);
}
function CustomerCard({ customer }: { customer?: AccountCustomer | null }) {
const name = [customer?.firstName, customer?.lastName].filter(Boolean).join(" ") || "Customer";
return (
<section
className="border-border bg-surface mt-8 rounded border p-8"
aria-labelledby="identity-heading"
>
<h2
id="identity-heading"
className="type-body text-on-surface-secondary mb-3 text-sm font-semibold tracking-[0.2em] uppercase"
>
Customer identity
</h2>
<p className="type-heading-lg mb-2">{name}</p>
{customer?.emailAddress?.emailAddress ? (
<p className="type-body text-on-surface-secondary">{customer.emailAddress.emailAddress}</p>
) : null}
<form method="post" action="/account/logout" className="mt-8">
<button
type="submit"
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Log out
</button>
</form>
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
import "server-only";
import { analyticsConsent as analyticsConsentConfig } from "@shared/config";
import { connection } from "next/server";
import { Suspense } from "react";
import { CartDrawer } from "@/components/CartDrawer";
import { ConsentBanner } from "@/components/ConsentBanner";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { HeaderAccountLink, HeaderAccountLinkFallback } from "@/components/HeaderAccountLink";
import { ShopifyScriptsClient } from "@/components/ShopifyScriptsClient";
import { getAnalyticsShop, getScriptsShop } from "@/lib/analytics-shop";
import { cartHandlers } from "@/lib/cart-handlers";
import { getStorefrontClient } from "@/lib/storefront";
import { Providers } from "./providers";
/**
* Async server shell that owns the per-request (dynamic) reads: the cart seed
* + the shop analytics GID. With `cacheComponents: true`, uncached/dynamic data
* accessed in a Server Component must sit inside a `<Suspense>` boundary so the
* static HTML shell prerenders and the per-buyer parts stream
* (`next/server` `connection()` + `headers()`/`cookies()` are per-request).
*
* Rendered inside `<Suspense>` from the root layout. `await connection()`
* opts the subtree into dynamic rendering (resolves immediately on a real
* request, never during prerender).
*/
export async function AppShell({ children }: { children: React.ReactNode }) {
await connection();
// Cart seed: per-buyer `getStorefrontClient()` (skill-mandated; the cart is
// personalized). Non-blocking via `Promise.race` (F1). On success pass the
// full `{cart, errors?}` envelope; on timeout/error pass `undefined` so the
// client fetches `/api/cart` after hydration (NOT `{cart:null}` — that would
// suppress the retry).
const storefrontClient = await getStorefrontClient();
let cartData: Awaited<ReturnType<typeof cartHandlers.get>>["data"] | undefined;
try {
const result = await Promise.race([
cartHandlers.get({ storefrontClient }),
timeoutReject(2000),
]);
cartData = result.data;
} catch (error) {
console.error("[hydrogen] Cart seed failed or timed out", error);
cartData = undefined;
}
// Analytics shop GID: best-effort, non-blocking (F1). Merged with
// `@shared/config` metadata (acceptedLanguage/currency/hydrogenSubchannelId).
const analyticsShop = await getAnalyticsShop();
const scriptsShop = getScriptsShop();
return (
<Providers
cartData={cartData}
analyticsShop={analyticsShop}
analyticsConsent={analyticsConsentConfig}
>
<a
href="#main-content"
className="focus-visible:bg-interactive focus-visible:text-interactive-text sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:start-4 focus-visible:top-4 focus-visible:z-50 focus-visible:rounded focus-visible:px-4 focus-visible:py-2"
>
Skip to content
</a>
<Header
accountLink={
<Suspense fallback={<HeaderAccountLinkFallback />}>
<HeaderAccountLink />
</Suspense>
}
/>
<main className="flex-1" id="main-content" tabIndex={-1}>
{children}
</main>
<Footer />
<CartDrawer />
<ConsentBanner />
<ShopifyScriptsClient shop={scriptsShop} />
</Providers>
);
}
function timeoutReject(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`cart seed timed out after ${ms}ms`)), ms);
});
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { CartCheckoutButton } from "@/components/CartCheckoutButton";
import { CartContent } from "@/components/CartContent";
import { CartViewedTracker } from "@/components/CartViewedTracker";
import { content } from "@/lib/content";
import { canonicalUrl } from "@/lib/site";
export const metadata: Metadata = {
title: "Cart",
description: content.cart.title,
alternates: { canonical: "/cart" },
openGraph: {
title: "Cart — CORE",
type: "website",
url: canonicalUrl("/cart"),
},
};
/**
* `/cart` — a real server-rendered cart page that is the drawer's no-JS fallback
* (engineering.md F4 + `notes/cart.md`). Renders `CartContent` (client) INSIDE
* the root layout's existing `CartProvider` — does **NOT** re-mount
* `CartProvider` and does **NOT** re-fetch the cart. The root layout's server
* seed already populates the SSR HTML (the no-JS contract). A per-page second
* `CartProvider` would create a divergent inner store vs the root-rendered
* `CartDrawer` — mutations would not reflect across them.
*
* `force-dynamic` is not allowed with `cacheComponents: true`; the root layout
* already makes every route dynamic via `cookies()`/`headers()` in the cart
* seed. Reachable via the footer `/cart` link (no-JS fallback).
*/
export default function CartPage() {
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<h1 className="type-display mb-8">{content.cart.title}</h1>
<div className="mx-auto max-w-2xl">
<CartContent />
<CartCheckoutButton />
<CartViewedTracker />
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { parseCollectionParams, type AvailableFilter } from "@shopify/hydrogen";
import type { ProductFilter as StorefrontApiProductFilter } from "@shopify/hydrogen/storefront-api-types";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { COLLECTION_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
type Props = {
params: Promise<{ handle: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { handle } = await params;
const { collection } = await fetchCollection(handle, undefined, "");
const title = collection?.title ?? "Collection";
const description = collection?.description ?? "";
return {
title,
description,
alternates: { canonical: `/collections/${handle}` },
openGraph: {
title: `${title} — CORE`,
description,
type: "website",
url: canonicalUrl(`/collections/${handle}`),
},
};
}
async function fetchCollection(
handle: string,
after: string | undefined,
searchString: string,
): Promise<{
collection: Awaited<ReturnType<typeof query>>["collection"] | null;
products: Awaited<ReturnType<typeof query>>["products"];
availableFilters: AvailableFilter[];
pageInfo: { hasNextPage: boolean; endCursor?: string | null };
}> {
"use cache";
cacheLife("minutes");
cacheTag("collections", "products");
// Reconstruct URLSearchParams from the serialized search string. `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get` — pass a
// plain string across the cache boundary and parse inside.
const browse = parseCollectionParams(new URLSearchParams(searchString));
const result = await query(handle, after, browse.filters, browse.sortKey, browse.reverse);
return result;
}
async function query(
handle: string,
after: string | undefined,
filters: ReturnType<typeof parseCollectionParams>["filters"],
sortKey: ReturnType<typeof parseCollectionParams>["sortKey"],
reverse: boolean,
) {
const { data, errors } = await staticStorefrontClient.graphql(COLLECTION_QUERY, {
variables: {
handle,
first: 24,
after,
filters:
filters.length > 0
? // F13: skill-sanctioned generated-type cast at the query variable
// boundary (hydrogen-collection-browser/references/react.md).
(filters as StorefrontApiProductFilter[])
: undefined,
sortKey,
reverse: reverse || undefined,
},
});
if (errors) {
console.error("[hydrogen] Collection query failed", errors);
}
const collection = data?.collection ?? null;
return {
collection,
products: collection?.products.nodes ?? [],
availableFilters: collection?.products.filters ?? [],
pageInfo: collection?.products.pageInfo ?? { hasNextPage: false },
};
}
export default async function CollectionPage({ params, searchParams }: Props) {
const { handle } = await params;
const urlSearch = toURLSearchParams(await searchParams);
const after = urlSearch.get("after") ?? undefined;
const { collection, products, availableFilters, pageInfo } = await fetchCollection(
handle,
after,
urlSearch.toString(),
);
// 404 only when the query has no GraphQL errors and the collection is missing
// (F8: don't blanket-404 on a Storefront auth error — errors are logged above).
if (!collection) {
notFound();
}
return (
<CollectionBrowser
mode="collection"
handle={collection.handle}
collection={{
id: collection.id,
handle: collection.handle,
title: collection.title,
description: collection.description,
descriptionHtml: collection.descriptionHtml,
}}
products={products}
availableFilters={availableFilters}
pageInfo={pageInfo}
dataSearch={urlSearch.toString()}
/>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { CollectionCard } from "@/components/CollectionCard";
import { content } from "@/lib/content";
import { COLLECTIONS_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
export const metadata: Metadata = {
title: "Collections",
description: "Browse all collections",
alternates: { canonical: "/collections" },
openGraph: {
title: "Collections — CORE",
type: "website",
url: canonicalUrl("/collections"),
},
};
async function fetchCollections(after?: string) {
"use cache";
cacheLife("hours");
cacheTag("collections");
const { data, errors } = await staticStorefrontClient.graphql(COLLECTIONS_QUERY, {
variables: { first: 24, after },
});
if (errors) {
console.error("[hydrogen] Collections query failed", errors);
}
return {
collections: data?.collections?.nodes ?? [],
pageInfo: data?.collections?.pageInfo ?? { hasNextPage: false },
};
}
export default async function CollectionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const after = typeof params.after === "string" ? params.after : undefined;
const { collections, pageInfo } = await fetchCollections(after);
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs items={[{ label: content.collections.title }]} />
</div>
<h1 className="type-display mb-8">{content.collections.title}</h1>
<ul role="list" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((collection, index) => (
<li key={collection.id}>
<CollectionCard
collection={collection}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
{pageInfo.hasNextPage ? (
<div className="mt-12 text-center">
<Link
href={`/collections?after=${encodeURIComponent(pageInfo.endCursor ?? "")}`}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Load more
</Link>
</div>
) : null}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import { content } from "@/lib/content";
/**
* Global error boundary (engineering.md F8). Next.js renders this when a route
* or layout throws. Must be a client component (`'use client'`) and render a
* `<html>`/`<body>` shell when used as `app/global-error.tsx`; this segment
* error boundary renders inside the existing root layout, so it only needs the
* error UI + a `reset` button.
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[hydrogen-example-nextjs] Route error:", error);
}, [error]);
return (
<div className="max-w-page px-margin mx-auto py-16">
<h1 className="type-heading-xl mb-4">Something went wrong</h1>
<p className="type-body text-on-surface-secondary mb-2">{error.message}</p>
{error.digest ? (
<p className="text-on-surface-secondary mb-8 text-sm">Error digest: {error.digest}</p>
) : null}
<button
type="button"
onClick={reset}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.general.back}
</button>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* Global error boundary (`app/global-error.tsx`) — renders when the root layout
* itself throws (engineering.md F8). Unlike `app/error.tsx` (a segment boundary
* that renders inside the root layout), this must render its own
* `<html>`/`<body>` shell and must NOT depend on any provider/context from the
* root layout (the layout is the thing that threw).
*
* Kept intentionally minimal (pure HTML + inline styles, no hooks that consume
* global context) to avoid the known Next.js 16 + React 19.2 `/_global-error`
* prerender bug (vercel/next.js#84994) where `AppRouterContext` is null during
* static generation of the internal error page.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body style={{ margin: 0, padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<div style={{ maxWidth: "40rem", margin: "0 auto" }}>
<h1 style={{ fontSize: "1.875rem", fontWeight: 300, marginBottom: "1rem" }}>
Something went wrong
</h1>
<p style={{ color: "#6b7280", marginBottom: "0.5rem" }}>{error.message}</p>
{error.digest ? (
<p style={{ color: "#6b7280", fontSize: "0.875rem", marginBottom: "2rem" }}>
Error digest: {error.digest}
</p>
) : null}
<button
type="button"
onClick={reset}
style={{
height: "2.75rem",
padding: "0 1.25rem",
borderRadius: "0.5rem",
border: "none",
background: "#1b4332",
color: "#fff",
fontSize: "0.875rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}
+951
View File
@@ -0,0 +1,951 @@
@import "tailwindcss";
/*
Storefront Kit examples core tokens.
Transcribed from examples/core/tokens.css — the single source of truth for
styling. The full token set, semantic component classes, and utilities are
inlined here so the React Router example's components can rely on the same
cross-example class anchors (button-primary, badge-sale, type-display,
overlay-dark, drawer-right, dialog-center, consent-banner, etc.).
*/
@theme {
/* Surfaces */
--color-surface: #ffffff;
--color-surface-secondary: #e3e3e3;
--color-on-surface: #000000;
--color-on-surface-secondary: #6b7280;
--color-border: #e5e7eb;
/* ─── Brand: the single source of truth ──────────────────────────────── */
--color-primary: #1b4332;
/* Interactive — derived from --color-primary. */
--color-interactive: var(--color-primary);
--color-interactive-hover: color-mix(in oklab, var(--color-primary) 85%, black);
--color-interactive-active: color-mix(in oklab, var(--color-primary) 72%, black);
--color-interactive-text: #ffffff;
/* Accent — a lighter, desaturated tint of the brand. */
--color-accent: oklch(from var(--color-primary) calc(l + 0.17) calc(c * 0.39) calc(h - 9));
/* Button variants */
--color-button-primary: var(--color-interactive);
--color-button-primary-hover: var(--color-interactive-hover);
--color-button-primary-active: var(--color-interactive-active);
--color-button-primary-text: var(--color-interactive-text);
--color-button-secondary: var(--color-surface-secondary);
--color-button-secondary-text: var(--color-on-surface);
--color-button-outline-border: var(--color-interactive);
--color-button-outline-text: var(--color-interactive);
/* Shop Pay brand button */
--color-button-shop-pay: #5a31f4;
--color-button-shop-pay-text: #ffffff;
/* Commerce */
--color-sale: #dc2626;
--color-compare: var(--color-on-surface-secondary);
/* Semantic states */
--color-success: #047857;
--color-warning: #b45309;
--color-critical: var(--color-sale);
--color-info: #2563eb;
/* Overlay */
--color-overlay: rgb(0 0 0 / 0.95);
--color-overlay-strong: rgb(0 0 0 / 0.7);
--color-overlay-subtle: rgb(0 0 0 / 0.5);
--color-overlay-scrim: rgb(0 0 0 / 0.25);
/* Hover */
--color-hover: rgb(0 0 0 / 0.03);
/* Radius */
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-lg: 0.75rem;
--radius-button: 0.5rem;
--radius-card: 0;
--radius-input: 0.5rem;
--radius-badge: 9999px;
/* Typography */
--font-body: system-ui, -apple-system, sans-serif;
--font-heading: system-ui, -apple-system, sans-serif;
/* Easing */
--ease-ui: cubic-bezier(0.16, 1, 0.3, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Layout spacing */
--spacing-page: 80rem;
--spacing-margin: 1rem;
--spacing-control-height: 2.125rem;
--spacing-touch-target: 2.75rem;
--spacing-filter-indicator: 1.25rem;
--spacing-filter-swatch: 1.75rem;
--spacing-dialog-max-height: 85dvh;
--spacing-search-modal-width: min(45rem, 90vw);
--spacing-search-modal-offset-block-start: 15dvh;
--spacing-hero: 60dvh;
--spacing-drawer-width: 480px;
--spacing-header-nav-inline: 6px;
--spacing-nav-dropdown-offset-inline: 13px;
--spacing-nav-dropdown-offset-block: 10px;
--spacing-nav-dropdown-enter-offset: 4px;
--spacing-nav-dropdown-surface-padding: 16px;
--spacing-nav-dropdown-column-width: 120px;
--spacing-nav-dropdown-gap-inline: 24px;
--spacing-nav-dropdown-item-padding-block: 3px;
--spacing-cart-count-badge: 22px;
--spacing-cart-line-thumbnail-width: 6rem;
/* Shadows */
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Aspect ratios */
--aspect-portrait: 4/5;
--aspect-landscape: 16/9;
}
:root {
--type-paragraph-lg-font: var(--font-body);
--type-paragraph-lg-size: 18px;
--type-paragraph-font: var(--font-body);
--type-paragraph-size: 16px;
--type-paragraph-sm-font: var(--font-body);
--type-paragraph-sm-size: 14px;
--type-heading-2xl-font: var(--font-heading);
--type-heading-2xl-size: 36px;
--type-heading-xl-font: var(--font-heading);
--type-heading-xl-size: 30px;
--type-heading-lg-font: var(--font-heading);
--type-heading-lg-size: 24px;
--type-heading-md-font: var(--font-heading);
--type-heading-md-size: 20px;
--type-heading-sm-font: var(--font-heading);
--type-heading-sm-size: 18px;
--type-subheading-font: var(--font-body);
--type-subheading-size: 12px;
--icon-stroke-width: 1.5;
}
@layer base {
h1 {
line-height: 1.2;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="url"],
input[type="number"],
input[type="date"],
textarea,
select {
width: 100%;
color: var(--color-on-surface);
background: var(--color-surface);
border-radius: var(--radius-input);
padding: var(--input-padding-y, 0.375rem) var(--input-padding-x, 0.75rem);
border: 1px solid var(--color-input-border, var(--color-border));
font-family: var(--font-body);
font-size: var(--input-font-size, 0.875rem);
line-height: 1.5;
appearance: none;
}
input::placeholder,
textarea::placeholder {
color: var(--color-on-surface-secondary);
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--color-interactive);
outline-offset: 2px;
}
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--color-surface-secondary);
}
:where(
.button-primary,
.button-secondary,
.button-outline,
.button-ghost,
.button-surface,
.button-shop-pay
) {
padding: var(--button-padding-y, 0.375rem) var(--button-padding-x, 0.75rem);
font-size: var(--button-font-size, 0.875rem);
}
:where(
.badge-default,
.badge-sale,
.badge-accent,
.badge-info,
.badge-success,
.badge-warning,
.badge-soldout
) {
padding: var(--badge-padding-y, 0.125rem) var(--badge-padding-x, 0.5rem);
font-size: var(--badge-font-size, 0.75rem);
}
@media (min-width: 48rem) {
:root {
--spacing-margin: 2.5rem;
}
}
}
@layer components {
.logo-size-custom {
width: auto;
}
.overlay-dark {
background: linear-gradient(
to top,
rgb(0 0 0 / 0.8) 0%,
rgb(0 0 0 / 0.4) 60%,
transparent 100%
);
}
.overlay-medium {
background: linear-gradient(
to top,
rgb(0 0 0 / 0.55) 0%,
rgb(0 0 0 / 0.2) 60%,
transparent 100%
);
}
.overlay-light {
background: linear-gradient(to top, rgb(0 0 0 / 0.3) 0%, transparent 70%);
}
.overlay-white {
background: linear-gradient(
to top,
rgb(255 255 255 / 0.9) 0%,
rgb(255 255 255 / 0.4) 60%,
transparent 100%
);
}
.swatch-sm {
width: 1.5rem;
height: 1.5rem;
}
.swatch-md {
width: 2rem;
height: 2rem;
}
.swatch-lg {
width: 2.5rem;
height: 2.5rem;
}
.swatch-scrim {
background: var(--color-overlay-scrim);
}
.filter-checkbox,
.filter-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-on-surface);
cursor: pointer;
}
.filter-checkbox {
block-size: var(--spacing-filter-indicator);
inline-size: var(--spacing-filter-indicator);
border-radius: var(--radius-sm);
}
.filter-swatch {
block-size: var(--spacing-filter-swatch);
inline-size: var(--spacing-filter-swatch);
border-radius: var(--radius-badge);
background: var(--filter-swatch-color, var(--swatch-color, var(--color-surface)));
}
.filter-checkbox[aria-checked="true"],
.filter-checkbox[aria-pressed="true"],
.filter-checkbox.is-selected,
input:checked + .filter-checkbox,
label:has(input:checked) .filter-checkbox {
background: var(--color-interactive);
color: var(--color-interactive-text);
border-color: var(--color-interactive);
}
.filter-swatch[aria-checked="true"],
.filter-swatch[aria-pressed="true"],
.filter-swatch.is-selected,
input:checked + .filter-swatch,
label:has(input:checked) .filter-swatch {
background:
linear-gradient(var(--color-overlay-scrim), var(--color-overlay-scrim)),
var(--filter-swatch-color, var(--swatch-color, var(--color-surface)));
color: var(--color-interactive-text);
border-color: var(--color-interactive);
}
.filter-checkbox:focus-visible,
.filter-swatch:focus-visible,
input:focus-visible + .filter-checkbox,
input:focus-visible + .filter-swatch,
label:has(input:focus-visible) .filter-checkbox,
label:has(input:focus-visible) .filter-swatch {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.filter-checkbox[aria-disabled="true"],
.filter-checkbox:disabled,
.filter-swatch[aria-disabled="true"],
.filter-swatch:disabled,
label:has(input:disabled) .filter-checkbox,
label:has(input:disabled) .filter-swatch {
opacity: 0.5;
cursor: not-allowed;
}
.filter-check-icon {
block-size: 0.875rem;
inline-size: 0.875rem;
color: var(--color-interactive-text);
stroke: currentColor;
fill: none;
opacity: 0;
pointer-events: none;
}
.filter-checkbox[aria-checked="true"] .filter-check-icon,
.filter-checkbox[aria-pressed="true"] .filter-check-icon,
.filter-checkbox.is-selected .filter-check-icon,
.filter-swatch[aria-checked="true"] .filter-check-icon,
.filter-swatch[aria-pressed="true"] .filter-check-icon,
.filter-swatch.is-selected .filter-check-icon,
input:checked + :is(.filter-checkbox, .filter-swatch) .filter-check-icon,
label:has(input:checked) .filter-check-icon {
opacity: 1;
}
.option-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: var(--spacing-touch-target);
min-inline-size: var(--spacing-touch-target);
padding-inline: 0.75rem;
padding-block: 0.375rem;
font-size: 0.875rem;
line-height: 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-on-surface);
cursor: pointer;
}
.option-pill[aria-pressed="true"],
.option-pill[aria-current="true"],
.option-pill.is-selected {
background: var(--color-interactive);
color: var(--color-interactive-text);
border-color: var(--color-interactive);
}
.option-pill:disabled,
.option-pill[aria-disabled="true"],
.option-pill.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.option-pill:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (hover: hover) {
.option-pill:not([aria-pressed="true"]):not([aria-current="true"]):not(.is-selected):not(
:disabled
):hover {
background: var(--color-surface-secondary);
}
}
.chip-filled {
background: var(--chip-bg, var(--color-surface-secondary));
color: var(--color-on-surface);
}
.chip-outlined {
background: var(--chip-bg, transparent);
color: var(--color-on-surface);
border: 1px solid var(--color-border);
}
@media (hover: hover) {
.chip-filled:hover,
.chip-outlined:hover {
background: color-mix(
in oklab,
var(--chip-bg, var(--color-surface-secondary)) 90%,
var(--color-on-surface)
);
}
}
.badge-default {
background: var(--color-badge-default, var(--color-surface-secondary));
color: var(--color-badge-default-text, var(--color-on-surface));
}
.badge-sale {
background: var(--color-badge-sale, var(--color-sale));
color: var(--color-badge-sale-text, var(--color-interactive-text));
}
.badge-accent {
background: var(--color-badge-accent, var(--color-accent));
color: var(--color-badge-accent-text, var(--color-interactive-text));
}
.badge-info {
background: var(--color-badge-info, var(--color-info));
color: var(--color-badge-info-text, var(--color-interactive-text));
}
.badge-success {
background: var(--color-badge-success, var(--color-success));
color: var(--color-badge-success-text, var(--color-interactive-text));
}
.badge-warning {
background: var(--color-badge-warning, var(--color-warning));
color: var(--color-badge-warning-text, var(--color-interactive-text));
}
.badge-soldout {
background: var(--color-badge-soldout, var(--color-on-surface));
color: var(--color-badge-soldout-text, var(--color-surface));
}
.button-primary {
background: var(--color-button-primary);
color: var(--color-button-primary-text);
}
.button-secondary {
background: var(--color-button-secondary);
color: var(--color-button-secondary-text);
border: 1px solid var(--color-border);
}
.button-outline {
background: transparent;
color: var(--color-button-outline-text);
border: 2px solid var(--color-button-outline-border);
}
.button-shop-pay {
background: var(--color-button-shop-pay);
color: var(--color-button-shop-pay-text);
}
.button-ghost,
.button-icon {
background: transparent;
color: var(--color-on-surface);
}
.button-icon {
min-block-size: var(--spacing-touch-target);
min-inline-size: var(--spacing-touch-target);
display: inline-flex;
align-items: center;
justify-content: center;
}
.button-surface {
background: var(--color-surface);
color: var(--color-on-surface);
}
:where(.button-primary, .button-secondary, .button-outline, .button-icon):is(
:disabled,
[aria-disabled="true"]
) {
opacity: 0.5;
}
@media (hover: hover) {
.button-primary:not(:disabled):not([aria-disabled="true"]):hover {
background: var(--color-button-primary-hover);
}
.button-secondary:not(:disabled):not([aria-disabled="true"]):hover {
background: color-mix(in oklab, var(--color-button-secondary) 90%, black);
}
.button-shop-pay:hover {
background: color-mix(in oklab, var(--color-button-shop-pay) 88%, black);
}
.button-outline:not(:disabled):not([aria-disabled="true"]):hover,
.button-ghost:hover {
background: var(--color-surface-secondary);
}
.button-surface:hover {
background: color-mix(in oklab, var(--color-surface) 90%, transparent);
}
.button-icon:not(:disabled):not([aria-disabled="true"]):hover {
opacity: 0.7;
}
}
.button-primary:not(:disabled):not([aria-disabled="true"]):active {
background: var(--color-button-primary-active);
}
.button-secondary:not(:disabled):not([aria-disabled="true"]):active {
background: color-mix(in oklab, var(--color-button-secondary) 82%, black);
}
.button-shop-pay:active {
background: color-mix(in oklab, var(--color-button-shop-pay) 78%, black);
}
.button-outline:not(:disabled):not([aria-disabled="true"]):active,
.button-ghost:active {
background: color-mix(in oklab, var(--color-surface-secondary) 88%, black);
}
.quantity-selector-outlined {
border: none;
background: transparent;
}
.quantity-selector-outlined:focus-within {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.quantity-selector-outlined button:focus-visible {
outline: none;
}
.quantity-selector-filled {
background: var(--color-surface-secondary);
border: 1px solid transparent;
}
.type-display {
font-size: var(--type-heading-2xl-size);
font-family: var(--type-heading-2xl-font);
font-weight: 300;
line-height: 1.25;
}
.type-heading-xl {
font-size: var(--type-heading-xl-size);
font-family: var(--type-heading-xl-font);
font-weight: 300;
}
.type-heading-lg {
font-size: var(--type-heading-lg-size);
font-family: var(--type-heading-lg-font);
font-weight: 300;
}
.type-heading-md {
font-size: var(--type-heading-md-size);
font-family: var(--type-heading-md-font);
font-weight: 400;
}
.type-heading-sm {
font-size: var(--type-heading-sm-size);
font-family: var(--type-heading-sm-font);
font-weight: 400;
}
.type-body-lg {
font-size: var(--type-paragraph-lg-size);
font-family: var(--type-paragraph-lg-font);
}
.type-body {
font-size: var(--type-paragraph-size);
font-family: var(--type-paragraph-font);
line-height: 1.625;
}
.type-body-sm {
font-size: var(--type-paragraph-sm-size);
font-family: var(--type-paragraph-sm-font);
}
.type-overline {
font-size: var(--type-subheading-size);
font-family: var(--type-subheading-font);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card {
position: relative;
}
.card-link {
text-decoration: none;
}
.card-link::after {
content: "";
position: absolute;
inset: 0;
}
.card-link:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.cart-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
block-size: var(--spacing-cart-count-badge);
inline-size: var(--spacing-cart-count-badge);
border-radius: var(--radius-badge);
background: var(--color-surface-secondary);
color: var(--color-on-surface);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
}
[data-header-nav-group] {
padding-inline: var(--spacing-header-nav-inline);
}
@media (min-width: 48rem) {
[data-header-nav-group] {
padding-inline: var(--spacing-margin);
}
}
.nav-has-dropdown {
position: relative;
}
.nav-dropdown {
position: absolute;
top: 100%;
inset-inline-start: calc(-1 * var(--spacing-nav-dropdown-offset-inline));
padding-block-start: var(--spacing-nav-dropdown-offset-block);
opacity: 0;
visibility: hidden;
transform: translateY(calc(-1 * var(--spacing-nav-dropdown-enter-offset)));
transition:
opacity 0.15s var(--ease-ui),
transform 0.15s var(--ease-ui),
visibility 0.15s;
z-index: 50;
pointer-events: none;
}
@media (hover: hover) {
.nav-has-dropdown:hover .nav-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
}
.nav-has-dropdown:focus-within .nav-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.nav-dropdown-inner {
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: var(--spacing-nav-dropdown-surface-padding);
display: grid;
grid-template-columns: var(--spacing-nav-dropdown-column-width);
grid-auto-flow: column;
gap: 0 var(--spacing-nav-dropdown-gap-inline);
min-width: var(--spacing-nav-dropdown-column-width);
}
.nav-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.75;
color: var(--color-on-surface);
text-decoration: none;
padding: var(--spacing-nav-dropdown-item-padding-block) 0;
white-space: nowrap;
transition: opacity 0.15s var(--ease-ui);
}
.nav-dropdown-inner:hover > .nav-dropdown-item {
opacity: 0.6;
}
.nav-dropdown-inner > .nav-dropdown-item:hover {
opacity: 1;
}
.nav-dropdown-item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Privacy / cookie consent banner. */
.consent-banner {
position: fixed;
inset-inline: 0;
inset-block-end: 0;
z-index: 60;
display: flex;
flex-direction: column;
gap: 1rem;
width: min(calc(100% - 2 * var(--spacing-margin)), var(--spacing-page));
margin-inline: auto;
margin-block-end: var(--spacing-margin);
padding: 1.25rem;
background: var(--color-surface);
color: var(--color-on-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.consent-banner-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 48rem) {
.consent-banner {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.consent-banner-actions {
flex-direction: row;
flex-shrink: 0;
align-items: center;
}
}
.remove-icon-top,
.remove-icon-bottom {
transition: transform 125ms ease-in-out;
}
}
@layer utilities {
.number-reset {
appearance: textfield;
}
.number-reset::-webkit-outer-spin-button,
.number-reset::-webkit-inner-spin-button {
appearance: none;
}
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
appearance: none;
-webkit-appearance: none;
display: none;
}
.marker-hidden {
list-style: none;
}
.marker-hidden::-webkit-details-marker {
display: none;
}
.scrollbar-none {
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
@media (min-width: 48rem) {
.product-grid {
grid-template-columns: 2fr 1fr;
}
}
.content-visibility-auto {
content-visibility: auto;
contain-intrinsic-block-size: auto var(--section-height, 500px);
}
.contain-paint {
contain: paint;
}
.button-primary:is(:disabled, [aria-disabled="true"]),
.button-secondary:is(:disabled, [aria-disabled="true"]),
.button-outline:is(:disabled, [aria-disabled="true"]),
.button-icon:is(:disabled, [aria-disabled="true"]) {
cursor: not-allowed;
}
.richtext {
color: var(--color-on-surface);
line-height: 1.625;
}
.richtext h1,
.richtext h2,
.richtext h3,
.richtext h4,
.richtext h5,
.richtext h6 {
font-family: var(--font-heading);
font-weight: 700;
color: var(--color-on-surface);
line-height: 1.25;
margin-block-start: 1.5rem;
margin-block-end: 0.75rem;
}
.richtext p {
margin-block-end: 1rem;
}
.richtext ul {
list-style: disc;
padding-inline-start: 1.5rem;
margin-block-end: 1rem;
}
.richtext ol {
list-style: decimal;
padding-inline-start: 1.5rem;
margin-block-end: 1rem;
}
.richtext a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.richtext > *:first-child {
margin-top: 0;
}
.richtext > *:last-child {
margin-bottom: 0;
}
.dialog-center {
border: none;
padding: 0;
margin: 0;
max-width: none;
max-height: none;
overflow: hidden;
background: var(--color-surface);
color: var(--color-on-surface);
box-shadow: var(--shadow-xl);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
}
.dialog-center::backdrop {
background: var(--color-overlay-scrim);
}
@media (min-width: 48rem) {
.dialog-center {
inset-block-start: var(--spacing-search-modal-offset-block-start);
inset-block-end: auto;
inset-inline-start: 50%;
inset-inline-end: auto;
transform: translateX(-50%);
width: var(--spacing-search-modal-width);
height: auto;
max-height: var(--spacing-dialog-max-height);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
}
@media (prefers-reduced-motion: no-preference) and (min-width: 48rem) {
.dialog-center[open] {
animation: dialog-center-enter 150ms var(--ease-ui);
}
}
@keyframes dialog-center-enter {
from {
opacity: 0;
transform: translateX(-50%) scale(0.98);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
.drawer-right,
.drawer-left {
border: none;
padding: 0;
margin: 0;
max-width: none;
max-height: none;
overflow: hidden;
background: var(--color-surface);
box-shadow: var(--shadow-xl);
position: fixed;
inset-block: 0;
width: 100vw;
height: 100%;
}
.drawer-right {
inset-inline-start: auto;
inset-inline-end: 0;
}
.drawer-left {
inset-inline-start: 0;
inset-inline-end: auto;
}
.drawer-right::backdrop,
.drawer-left::backdrop {
background: rgb(0 0 0 / 0.5);
}
@media (min-width: 480px) {
.drawer-right,
.drawer-left {
width: var(--spacing-drawer-width);
}
}
}
@utility bleed-full {
margin-inline: calc(-1 * var(--spacing-margin) - max(0px, (100vw - var(--spacing-page)) / 2));
width: calc(100% + 2 * var(--spacing-margin) + max(0px, 100vw - var(--spacing-page)));
}
@utility gallery-bleed-start {
margin-inline-start: calc(
-1 * var(--spacing-margin) - max(0px, (100vw - var(--spacing-page)) / 2)
);
width: calc(100% + var(--spacing-margin) + max(0px, 100vw - var(--spacing-page)) / 2);
}
/* Cart drawer dialog shell (hydrogen-cart-drawer skill reference CSS). */
dialog#cart-drawer {
transform: translateX(100%);
transition:
transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}
dialog#cart-drawer[open] {
transform: translateX(0);
}
@starting-style {
dialog#cart-drawer[open] {
transform: translateX(100%);
}
}
dialog#cart-drawer::backdrop {
background: rgb(0 0 0 / 0);
transition:
background-color 250ms ease-out,
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}
dialog#cart-drawer[open]::backdrop {
background: rgb(0 0 0 / 0.3);
}
@starting-style {
dialog#cart-drawer[open]::backdrop {
background: rgb(0 0 0 / 0);
}
}
/* Body scroll lock while the cart drawer is open (pure CSS, no JS toggling). */
body:has(dialog#cart-drawer[open]) {
overflow: hidden;
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import "./globals.css";
import { SITE_ORIGIN } from "@/lib/site";
import { AppShell } from "./app-shell";
/**
* Root layout (engineering.md F1, F4, F7, F10). With `cacheComponents: true`,
* the layout is a **static shell** — it prerenders the `<html>`/`<body>` +
* announcement bar, then wraps the per-request (dynamic) `AppShell` (cart seed
* + analytics shop + chrome) in `<Suspense>` so the dynamic parts stream while
* the static shell serves immediately. `AppShell` calls `connection()` to opt
* the subtree into dynamic rendering.
*
* `metadataBase` is set here for canonical/OG URL resolution (F10).
*/
export const metadata: Metadata = {
metadataBase: new URL(SITE_ORIGIN),
title: {
default: "CORE — Discover our latest collection",
template: "%s — CORE",
},
description: "Explore our curated selection of premium products",
icons: {
icon: { url: "/favicon.svg", type: "image/svg+xml" },
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-surface text-on-surface font-body flex min-h-svh flex-col antialiased">
<div
role="region"
aria-label="Announcement"
className="bg-on-surface px-margin py-2.5 text-center"
>
<p className="type-body-sm text-surface">Free shipping on orders over $50</p>
</div>
<Suspense
fallback={
<div className="bg-surface text-on-surface-secondary flex-1" aria-busy="true" />
}
>
<AppShell>{children}</AppShell>
</Suspense>
</body>
</html>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { handleShopifyRedirects } from "@shopify/hydrogen";
import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { routeTemplates } from "@/lib/route-templates";
import { getStorefrontClient } from "@/lib/storefront";
/**
* Next.js 404 + Shopify URL redirects (`hydrogen-request-handlers` /
* `references/nextjs.md`). `proxy.ts` cannot inspect the routed response, so
* Storefront URL redirects run here, after the framework returns a 404.
*
* With `cacheComponents: true`, the per-request reads (`headers()` +
* `getStorefrontClient()` + `redirect()`) must sit inside a `<Suspense>`
* boundary. The static 404 shell prerenders; the `<RedirectChecker>` streams
* and either `redirect()`s to a matching Shopify URL redirect or renders
* nothing (leaving the 404 shell visible).
*
* The forwarded `x-storefront-url` header (set by `proxy.ts`) carries the
* original URL so `handleShopifyRedirects` can match it.
*
* Gotcha (N2): Next's `redirect()` does not preserve Hydrogen's `301` status.
*/
export default function NotFound() {
return (
<main className="max-w-page px-margin mx-auto w-full py-16 text-center">
<Suspense fallback={null}>
<RedirectChecker />
</Suspense>
<h1 className="type-display mb-4">Page not found</h1>
<p className="type-body text-on-surface-secondary mb-8">
The page you’re looking for doesn’t exist.
</p>
<Link
href="/"
className="rounded-button button-primary inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline"
>
Back to home
</Link>
</main>
);
}
async function RedirectChecker() {
await connection();
const requestHeaders = await headers();
const url = requestHeaders.get("x-storefront-url");
if (url) {
const result = await handleShopifyRedirects({
request: new Request(url),
storefrontClient: await getStorefrontClient(),
routeTemplates,
});
const location = result?.headers.get("location");
if (location) redirect(location);
}
return null;
}
+169
View File
@@ -0,0 +1,169 @@
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { CollectionCard } from "@/components/CollectionCard";
import { ProductCard } from "@/components/ProductCard";
import { content } from "@/lib/content";
import { HOME_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
export const metadata: Metadata = {
title: "CORE — Discover our latest collection",
description: content.home.hero.subtitle,
alternates: { canonical: "/" },
openGraph: {
title: "CORE — Discover our latest collection",
description: content.home.hero.subtitle,
type: "website",
url: canonicalUrl("/"),
},
twitter: { card: "summary_large_image" },
};
/** Home catalog fetch — cached at the data boundary (F2: dynamic root layout
* precludes page-level prerender, so the cache-point is the fetch function). */
async function fetchHome() {
"use cache";
cacheLife("minutes");
cacheTag("products", "collections");
const { data, errors } = await staticStorefrontClient.graphql(HOME_QUERY);
if (errors) {
console.error("[hydrogen] Home query failed", errors);
}
return {
featuredProducts: data?.featuredProducts?.nodes ?? [],
featuredCollections: data?.featuredCollections?.nodes ?? [],
};
}
type HomeData = Awaited<ReturnType<typeof fetchHome>>;
type ProductNode = HomeData["featuredProducts"][number];
type CollectionNode = HomeData["featuredCollections"][number];
export default async function HomePage() {
const { featuredProducts, featuredCollections } = await fetchHome();
return (
<>
<Hero />
<BestSellers products={featuredProducts} />
<ShopByCategory collections={featuredCollections} />
</>
);
}
function Hero() {
// The hero is the LCP image (notes/home.md): eager + high fetch priority.
// Unsplash is a third-party host, so it passes through `shopifyImageUrl`
// unchanged — build a width-descriptor srcset directly from its `w=` param
// and pair it with `sizes="100vw"` (F12). DPR `srcSetFor` is not used here:
// `sizes` is a no-op for 1x/2x descriptors.
const heroBaseUrl =
"https://images.unsplash.com/photo-1653398597732-37fc919284dd?auto=format&fit=crop&q=80";
const heroSrcSet = `${heroBaseUrl}&w=480 480w, ${heroBaseUrl}&w=1024 1024w, ${heroBaseUrl}&w=2000 2000w`;
return (
<section className="max-w-page px-margin mx-auto w-full">
<div
className="bleed-full min-h-hero relative overflow-hidden"
aria-labelledby="hero-heading"
>
<div className="bg-surface-secondary absolute inset-0">
<img
src={`${heroBaseUrl}&w=2000`}
srcSet={heroSrcSet}
sizes="100vw"
alt="A white chair beside a white wall"
className="h-full w-full object-cover"
loading="eager"
fetchPriority="high"
/>
</div>
<div className="overlay-dark pointer-events-none absolute inset-0" />
<div className="max-w-page px-margin text-interactive-text min-h-hero relative z-10 mx-auto flex flex-col items-start justify-end p-8 pb-12">
<h1 id="hero-heading" className="type-display mb-3 max-w-2xl">
{content.home.hero.heading}
</h1>
<p className="type-body-lg mb-6 max-w-prose opacity-90">{content.home.hero.subtitle}</p>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/collections"
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center gap-2 px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.home.hero.primaryCta}
</Link>
<Link
href="/collections"
className="rounded-button button-secondary focus-visible:outline-accent inline-flex h-11 items-center justify-center gap-2 px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.home.hero.secondaryCta}
</Link>
</div>
</div>
</div>
</section>
);
}
function BestSellers({ products }: { products: ProductNode[] }) {
return (
<section className="bg-surface w-full pt-20 pb-12">
<div className="max-w-page px-margin mx-auto mb-4 flex items-center justify-between">
<h2 className="type-heading-xl">{content.home.bestSellers}</h2>
<Link
href="/collections"
className="min-h-touch-target text-on-surface focus-visible:outline-accent inline-flex items-center gap-1 rounded-sm text-sm font-normal no-underline hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-opacity"
>
<span>{content.home.viewAll}</span>
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</Link>
</div>
<div className="max-w-page px-margin mx-auto contain-paint">
<ul role="list" className="grid grid-cols-1 gap-x-1 gap-y-10 md:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<li key={product.id}>
<ProductCard product={product} loading="lazy" />
</li>
))}
</ul>
</div>
</section>
);
}
function ShopByCategory({ collections }: { collections: CollectionNode[] }) {
return (
<section className="w-full py-12" aria-labelledby="category-heading">
<h2 id="category-heading" className="type-heading-xl max-w-page px-margin mx-auto mb-4">
{content.home.shopByCategory}
</h2>
<ul role="list" className="max-w-page px-margin mx-auto grid grid-cols-1 md:grid-cols-3">
{collections.map((collection) => (
<li key={collection.id}>
<CollectionCard collection={collection} loading="lazy" />
</li>
))}
</ul>
</section>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { getSelectedProductOptions } from "@shopify/hydrogen";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ProductCard, type ProductCardData } from "@/components/ProductCard";
import { ProductDetails } from "@/components/ProductDetails";
import { content } from "@/lib/content";
import { PRODUCT_QUERY, RELATED_PRODUCTS_QUERY, type ProductData } from "@/lib/product-query";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
type Props = {
params: Promise<{ handle: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { handle } = await params;
// Re-fetch the product for metadata. It hits the same `use cache` cache-point
// as the page render, so this is a cache hit in practice.
const { product } = await fetchProduct(handle, "");
const title = product?.title ?? "Product";
return {
title,
description: product?.description ?? "",
// Variant params do NOT change the canonical (F10).
alternates: { canonical: `/products/${handle}` },
openGraph: {
title: `${title} — CORE`,
description: product?.description ?? "",
url: canonicalUrl(`/products/${handle}`),
},
twitter: { card: "summary_large_image" },
};
}
async function fetchProduct(
handle: string,
searchString: string,
): Promise<{ product: ProductData | null }> {
"use cache";
cacheLife("minutes");
cacheTag("products");
// Read variant selection from URL option params (F4: a no-JS GET to
// `?Size=Large&Color=Green` resolves the variant server-side). Do NOT pass
// `optionNames: []` — an empty allow-list filters out every param.
// Reconstruct URLSearchParams from the serialized search string — `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get`.
const selectedOptions = getSelectedProductOptions(new URLSearchParams(searchString));
const { data, errors } = await staticStorefrontClient.graphql(PRODUCT_QUERY, {
variables: { handle, selectedOptions },
});
if (errors) {
console.error("[hydrogen] Product query failed", errors);
}
return { product: data?.product ?? null };
}
export default async function ProductPage({ params, searchParams }: Props) {
const { handle } = await params;
const urlSearch = toURLSearchParams(await searchParams);
const { product } = await fetchProduct(handle, urlSearch.toString());
// 404 only when the query has no GraphQL errors and the product is missing
// (F8: errors are logged above; don't blanket-404 on a Storefront auth error).
if (!product) {
notFound();
}
return (
<>
<ProductDetails product={product} />
{/* Best-effort related products (F14): a separate async server child in a
<Suspense> boundary that degrades silently — never blocks the PDP. */}
<Suspense fallback={null}>
<RelatedProducts handle={handle} />
</Suspense>
</>
);
}
/**
* Related products (`notes/product.md` "you may also like"). Best-effort: any
* error/timeout returns an empty list so the PDP never breaks. Fetched via the
* shared `staticStorefrontClient` inside a `use cache` cache-point.
*/
async function RelatedProducts({ handle }: { handle: string }) {
"use cache";
cacheLife("minutes");
cacheTag("products");
let related: ProductCardData[] = [];
try {
const { data, errors } = await staticStorefrontClient.graphql(RELATED_PRODUCTS_QUERY, {
variables: { handle },
});
if (errors) {
console.error("[hydrogen] Related products query failed", errors);
}
if (data?.product?.relatedProducts) {
const all = data.product.relatedProducts.nodes.flatMap((node) => node.products.nodes);
related = all.filter((p) => p.handle !== handle).slice(0, 4);
}
} catch (error) {
// Related products are an enhancement; never break the PDP.
console.error("[hydrogen] Related products failed", error);
}
if (related.length === 0) return null;
return (
<section className="max-w-page px-margin mx-auto mt-16">
<h2 className="type-heading-xl mb-6">{content.product.relatedProducts}</h2>
<ul role="list" className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-4">
{related.map((relatedProduct) => (
<li key={relatedProduct.id}>
<ProductCard product={relatedProduct} loading="lazy" />
</li>
))}
</ul>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import type { ConsentConfig, ShopAnalytics } from "@shopify/hydrogen";
import { Suspense } from "react";
import type { ReactNode } from "react";
import { AnalyticsTracker } from "@/components/AnalyticsTracker";
import { CartAnalyticsTracker } from "@/components/CartAnalyticsTracker";
import { CartProvider } from "@/lib/cart";
import type { cartHandlers } from "@/lib/cart-handlers";
/**
* Client `CartProvider` wrapper + root analytics trackers
* (`hydrogen-cart-ui` / `references/react.md` Next.js App Router section +
* `hydrogen-analytics` / `references/react.md`). The root server layout reads
* the cart seed and the analytics shop GID on the server, then passes them here.
*
* `cartData` is the full handler data envelope (`{cart, errors?}`). It is
* `undefined` when the server cart read timed out or errored — **not**
* `{cart: null}`. `{cart: null}` means "server confirmed no usable cart"
* (suppresses the post-hydration `/api/cart` fetch); `undefined` means "no
* bootstrap → fetch `/api/cart` after hydration" so the count self-corrects.
* `CartProvider` accepts `undefined` (F1).
*
* `AnalyticsTracker` reads `usePathname` + `useSearchParams`; it stays inside
* the `<Suspense>` boundary the layout wraps around `Providers` so the
* `useSearchParams()` CSR bailout is scoped to the tracker, not the layout.
*/
export type CartData = Awaited<ReturnType<typeof cartHandlers.get>>["data"];
export function Providers({
cartData,
analyticsShop,
analyticsConsent,
children,
}: {
cartData?: CartData;
analyticsShop: ShopAnalytics;
analyticsConsent: ConsentConfig;
children: ReactNode;
}) {
return (
<CartProvider initialData={cartData}>
{/* Suspense scopes the `useSearchParams()` CSR bailout to the tracker,
not the whole layout (hydrogen-analytics / references/react.md). */}
<Suspense fallback={null}>
<AnalyticsTracker shop={analyticsShop} consent={analyticsConsent} />
</Suspense>
<CartAnalyticsTracker />
{children}
</CartProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
import { SITE_ORIGIN } from "@/lib/site";
/**
* `/robots.txt` (engineering.md F10). Allows all crawlers, points to the
* sitemap, and disallows the cart + API surfaces.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/cart", "/api/"],
},
sitemap: `${SITE_ORIGIN}/sitemap.xml`,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { parseCollectionParams, type StorefrontApi } from "@shopify/hydrogen";
import type { AvailableFilter } from "@shopify/hydrogen";
import type { ProductFilter as StorefrontApiProductFilter } from "@shopify/hydrogen/storefront-api-types";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { SEARCH_QUERY } from "@/lib/queries";
import { canonicalUrl } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
import { toURLSearchParams } from "@/lib/url-params";
export const metadata: Metadata = {
title: "Search",
description: "Search products",
alternates: { canonical: "/search" },
openGraph: {
title: "Search — CORE",
type: "website",
url: canonicalUrl("/search"),
},
};
type SearchResult = {
term: string;
products: SearchProductNode[];
availableFilters: AvailableFilter[];
pageInfo: { hasNextPage: boolean; endCursor?: string | null };
totalCount: number;
};
/** A `search.nodes` element narrowed to the `Product` branch (includes the
* `__typename` discriminator + the `ProductCard` fragment fields). */
type SearchNode = NonNullable<
StorefrontApi.ResultOf<typeof SEARCH_QUERY>["search"]
>["nodes"][number];
type SearchProductNode = Extract<SearchNode, { __typename: "Product" }>;
async function fetchSearch(term: string, searchString: string): Promise<SearchResult> {
"use cache";
cacheLife("minutes");
cacheTag("products");
if (!term) {
return {
term: "",
products: [],
availableFilters: [],
pageInfo: { hasNextPage: false, endCursor: null },
totalCount: 0,
};
}
// Reconstruct URLSearchParams from the serialized search string. `use cache`
// serializes arguments, so a URLSearchParams passed in loses `.get` — pass a
// plain string across the cache boundary and parse inside.
const searchParams = new URLSearchParams(searchString);
const browse = parseCollectionParams(searchParams);
// parseCollectionParams returns a ProductCollectionSortKeys; search only
// supports PRICE/RELEVANCE. Map unsupported sorts back to RELEVANCE.
const searchSortKey = browse.sortKey === "PRICE" ? "PRICE" : "RELEVANCE";
const { data, errors } = await staticStorefrontClient.graphql(SEARCH_QUERY, {
variables: {
query: term,
first: 24,
after: searchParams.get("after") ?? undefined,
sortKey: searchSortKey,
reverse: browse.reverse || undefined,
productFilters:
browse.filters.length > 0
? // F13: skill-sanctioned generated-type cast at the query variable
// boundary (hydrogen-collection-browser/references/react.md).
(browse.filters as StorefrontApiProductFilter[])
: undefined,
},
});
if (errors) {
console.error("[hydrogen] Search query failed", errors);
}
const search = data?.search;
const products = search?.nodes ?? [];
// Filter to Product nodes only (search is heterogeneous; `__typename` guard
// per feedback Round 1 + Round 2 #1).
const productNodes = products.filter(
(node): node is SearchProductNode => node?.__typename === "Product",
);
return {
term,
products: productNodes,
availableFilters: search?.productFilters ?? [],
pageInfo: search?.pageInfo ?? { hasNextPage: false },
totalCount: productNodes.length,
};
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const urlSearch = toURLSearchParams(await searchParams);
const term = urlSearch.get("q")?.trim() ?? "";
const result = await fetchSearch(term, urlSearch.toString());
return (
<CollectionBrowser
mode="search"
term={result.term}
products={result.products}
availableFilters={result.availableFilters}
pageInfo={result.pageInfo}
totalCount={result.totalCount}
dataSearch={urlSearch.toString()}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { MetadataRoute } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { SITEMAP_QUERY } from "@/lib/queries";
import { SITE_ORIGIN } from "@/lib/site";
import { staticStorefrontClient } from "@/lib/storefront-static";
/**
* `/sitemap.xml` (engineering.md F10). Lists product + collection URLs with
* `updatedAt` timestamps, fetched via the shared `staticStorefrontClient`
* inside a `use cache` cache-point (catalog, not personalized). mock.shop
* fallback works (queries mock.shop).
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries = await fetchSitemap();
return entries.map((entry) => ({
url: entry.loc,
lastModified: entry.lastmod ?? undefined,
}));
}
type SitemapEntry = { loc: string; lastmod?: string };
async function fetchSitemap(): Promise<SitemapEntry[]> {
"use cache";
cacheLife("hours");
cacheTag("products", "collections");
const { data, errors } = await staticStorefrontClient.graphql(SITEMAP_QUERY);
if (errors) {
console.error("[hydrogen] Sitemap query failed", errors);
}
const entries: SitemapEntry[] = [
{ loc: `${SITE_ORIGIN}/` },
{ loc: `${SITE_ORIGIN}/collections` },
{ loc: `${SITE_ORIGIN}/search` },
];
for (const collection of data?.collections?.nodes ?? []) {
entries.push({
loc: `${SITE_ORIGIN}/collections/${collection.handle}`,
lastmod: collection.updatedAt ?? undefined,
});
}
for (const product of data?.products?.nodes ?? []) {
entries.push({
loc: `${SITE_ORIGIN}/products/${product.handle}`,
lastmod: product.updatedAt ?? undefined,
});
}
return entries;
}