Template
Initial commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+951
@@ -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;
|
||||
}
|
||||
+169
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user