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
+35
View File
@@ -0,0 +1,35 @@
"use client";
import type { ConsentConfig, ShopAnalytics } from "@shopify/hydrogen";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { AnalyticsEvent, configureAnalytics, getAnalytics } from "@/lib/analytics";
/**
* Root analytics tracker (`hydrogen-analytics` / `references/react.md` Next.js
* App Router shape). Keys the `PAGE_VIEWED` effect by `pathname + "?" + search`
* so client navigations fire a fresh page view (F9: no polling). Wrapped in
* `<Suspense>` in `Providers` so the `useSearchParams()` CSR bailout is scoped
* to the tracker, not the whole layout.
*/
export function AnalyticsTracker({
shop,
consent,
}: {
shop: ShopAnalytics;
consent: ConsentConfig;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const pageKey = `${pathname}?${searchParams?.toString() ?? ""}`;
useEffect(() => {
configureAnalytics(shop, consent);
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.PAGE_VIEWED);
}, [pageKey, shop, consent]);
return null;
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
export type Crumb = {
label: string;
href?: string;
};
type BreadcrumbsProps = {
items: Crumb[];
};
/**
* Server-rendered breadcrumb trail. The last crumb is `aria-current="page"`.
* Reused by collection, collections, search, and product routes (F13).
*/
export function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb">
<ol
role="list"
className="text-on-surface-secondary flex flex-wrap items-center gap-1 text-sm"
>
<li key="home">
<Link href="/" className="hover:text-on-surface no-underline">
Home
</Link>
</li>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={`${item.label}-${index}`} className="flex items-center gap-1">
<span aria-hidden="true">/</span>
{item.href && !isLast ? (
<Link href={item.href} className="hover:text-on-surface no-underline">
{item.label}
</Link>
) : (
<span
className="text-on-surface"
{...(isLast ? { "aria-current": "page" as const } : {})}
>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef } from "react";
import { getAnalytics } from "@/lib/analytics";
import { useCart } from "@/lib/cart";
/**
* Subscribes to cart store changes and forwards server-confirmed cart data to
* `analytics.updateCart()` (`hydrogen-analytics`). The bus derives
* `cart_updated` / `product_added_to_cart` / `product_removed_to_cart` from
* cart deltas — app code should never publish those manually.
*
* The cart query includes `updatedAt` (see `app/lib/cart-handlers.ts`), which
* the bus uses for dedupe. Without it, every `updateCart()` call is ignored.
*/
export function CartAnalyticsTracker() {
const cart = useCart((state) => state.data);
const prevUpdatedAt = useRef<string | null>(null);
useEffect(() => {
const analytics = getAnalytics();
if (!analytics) return;
// Only update when the cart has a usable id + updatedAt (the bus ignores
// carts without updatedAt).
if (!cart.id || !cart.updatedAt) return;
if (prevUpdatedAt.current === cart.updatedAt) return;
prevUpdatedAt.current = cart.updatedAt;
analytics.updateCart(cart);
}, [cart]);
return null;
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCart } from "@/lib/cart";
import { content } from "@/lib/content";
/**
* `/cart` checkout button — reads the cart store (client) for `checkoutUrl` and
* `totalQuantity`. Hidden when the cart is empty. Rendered as a client island
* inside the server-rendered `/cart` page so it reflects the live cart.
*/
export function CartCheckoutButton() {
const totalQuantity = useCart((state) => state.data.totalQuantity);
const checkoutUrl = useCart((state) => state.data.checkoutUrl);
const isEmpty = totalQuantity === 0;
if (isEmpty || !checkoutUrl) return null;
return (
<div className="border-border mt-6 border-t pt-4">
<a
href={checkoutUrl}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 w-full items-center justify-center px-4 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.cart.checkout}
</a>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useCart, useCartForm } from "@/lib/cart";
import { content } from "@/lib/content";
import { formatPrice } from "@/lib/money";
import { CartLineItem } from "./CartLineItem";
/**
* Shared cart content — line items, discount code form, and totals. Used by
* both the cart drawer and the `/cart` page (`hydrogen-cart-ui` /
* `hydrogen-cart-drawer`). The drawer wraps this in fixed header/body/footer
* zones; the page wraps it in a page layout.
*
* Layout: the line-item list flexes to fill the available height and scrolls on
* its own; the discount code + estimated-total block is pinned to the bottom
* (feedback: bottom-align the footer block). The order note was removed — its
* "save note" button was non-functional and the entry point is gone.
*/
export function CartContent() {
const cart = useCart((state) => state.data);
const loading = useCart((state) => state.loading);
const lines = cart.lines.nodes;
const totalQuantity = cart.totalQuantity;
const subtotal = cart.cost.subtotalAmount;
const discountCodes = cart.discountCodes;
const { formProps, register } = useCartForm();
if (loading) {
return (
<div className="divide-border divide-y" aria-busy="true">
<p className="text-on-surface-secondary py-8 text-center text-sm">Loading cart…</p>
</div>
);
}
if (totalQuantity === 0 || lines.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<p className="type-body-sm text-on-surface font-medium">{content.cart.empty}</p>
<p className="text-on-surface-secondary text-sm">{content.cart.emptyDescription}</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<ul role="list" className="divide-border min-h-0 flex-1 divide-y overflow-y-auto">
{lines.map((line) => (
<li key={line.id}>
<CartLineItem line={line} />
</li>
))}
</ul>
<div className="border-border mt-3 shrink-0 border-t pt-3">
<div className="flex flex-col gap-3">
{/* Discount apply form */}
<form {...formProps()} className="flex gap-2">
<input
type="text"
{...register("discountCode", { defaultValue: "" })}
placeholder="Discount code"
aria-label="Discount code"
className="number-reset rounded-button border-border h-11 flex-1 border px-3 text-sm"
/>
<button
type="submit"
{...register("discount-apply")}
className="rounded-button button-secondary inline-flex h-11 items-center justify-center px-4 text-sm font-medium"
>
Apply
</button>
</form>
{/* Applied discount codes — each removal is its own form */}
{discountCodes.map((code) => (
<form {...formProps()} key={code.code} className="flex items-center gap-2">
<input type="hidden" {...register("discountCode", { value: code.code })} />
<span className="chip-filled rounded-full px-3 py-1 text-sm">{code.code}</span>
<button
type="submit"
{...register("discount-remove")}
className="button-icon rounded text-sm"
aria-label={`Remove discount ${code.code}`}
>
<img
src="/icons/icon-x.svg"
width="16"
height="16"
alt=""
className="size-4"
aria-hidden="true"
/>
</button>
</form>
))}
{/* Estimated total */}
<div className="flex items-center justify-between">
<span className="text-on-surface text-sm font-medium">{content.cart.totalLabel}</span>
<span className="text-on-surface text-base font-medium">{formatPrice(subtotal)}</span>
</div>
<p className="text-on-surface-secondary text-xs">
{content.cart.taxesAndShippingAtCheckout}
</p>
</div>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useCart } from "@/lib/cart";
import { CART_DRAWER_ID, closeCartDrawer, configureOpenCartAction } from "@/lib/cart-drawer";
import { content } from "@/lib/content";
import { CartContent } from "./CartContent";
/**
* Cart drawer — a native `<dialog>` + `showModal()` rendered once in the root
* layout (`hydrogen-cart-drawer` skill). Opens via `openCartDrawer()`,
* `window.Shopify.actions.openCart()`, or after a successful add-to-cart. The
* `/cart` route is the no-JS fallback (F4). Uses fixed header/body/footer
* zones; the body scrolls, the footer is hidden when the cart is empty.
*/
export function CartDrawer() {
// Ensure the Standard Actions `openCart` handler is registered on the client.
configureOpenCartAction();
const totalQuantity = useCart((state) => state.data.totalQuantity);
const isEmpty = totalQuantity === 0;
const checkoutUrl = useCart((state) => state.data.checkoutUrl);
return (
<dialog
id={CART_DRAWER_ID}
className="drawer-right bg-surface text-on-surface"
aria-labelledby="cart-drawer-title"
closedby="any"
>
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center py-2 ps-4 pe-2">
<div className="flex flex-1 items-center gap-2">
<h2 id="cart-drawer-title" className="text-on-surface text-lg font-medium">
{content.cart.title}
</h2>
<span className="cart-count-badge" aria-hidden="true">
{totalQuantity}
</span>
</div>
<button
type="button"
onClick={closeCartDrawer}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
<div className="min-h-0 flex-1 p-4">
<CartContent />
</div>
{!isEmpty ? (
<div className="border-border shrink-0 border-t p-4">
<a
href={checkoutUrl ?? "#"}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 w-full items-center justify-center gap-2 px-4 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.cart.checkout}
</a>
</div>
) : null}
</div>
</dialog>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useCart, useCartForm } from "@/lib/cart";
import { shopifyImageUrl } from "@/lib/image";
import { formatPrice } from "@/lib/money";
/**
* A cart line, derived from the typed `useCart` binding (F3: consume typed data,
* no hand-rolled parallel shape). Narrowed field access stays tolerant of the
* gql.tada-inferred merchandise union via optional chaining
* (`hydrogen-cart-ui` / `references/react.md`).
*/
type CartState = Parameters<Parameters<typeof useCart>[0]>[0];
type CartLine = CartState["data"]["lines"]["nodes"][number];
/**
* Shared cart line-item form — used by both the cart drawer and the `/cart`
* page (`hydrogen-cart-ui` / `hydrogen-cart-drawer`). Preserves the
* progressive-enhancement form contract: hidden `register("set")`, scoped
* `register("lineId", { value })`, and a real editable quantity input from
* `register("quantity", { value, interactive: true })`. Increase/decrease/remove
* are additional submit controls, not replacements.
*/
export function CartLineItem({ line }: { line: CartLine }) {
const { formProps, register } = useCartForm();
const pendingLines = useCart((state) => state.pending.lines);
const isPending = pendingLines.has(line.id);
const merchandise = line.merchandise;
const productTitle = merchandise?.product?.title ?? merchandise?.title ?? "Product";
const productHandle = merchandise?.product?.handle;
const selectedOptions = merchandise?.selectedOptions ?? [];
const variantSubtitle = selectedOptions.map((option) => option.value).join(" / ");
const totalAmount = line.cost.totalAmount;
const compareAt = line.cost.compareAtAmountPerQuantity ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(totalAmount.amount);
return (
<div
className="grid grid-cols-[var(--spacing-cart-line-thumbnail-width)_1fr_auto] items-stretch gap-3 py-4"
{...(isPending ? { "aria-busy": "true" } : {})}
>
<div className="bg-surface-secondary h-full w-full overflow-hidden">
{merchandise?.image ? (
<img
src={shopifyImageUrl(merchandise.image.url, { width: 128 })}
alt={merchandise.image.altText ?? `${productTitle} in ${variantSubtitle}`}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0">
{productHandle ? (
<a
href={`/products/${productHandle}`}
className="type-body-sm text-on-surface font-medium no-underline hover:opacity-70"
>
{productTitle}
</a>
) : (
<p className="type-body-sm text-on-surface font-medium">{productTitle}</p>
)}
{variantSubtitle ? (
<p className="text-on-surface-secondary mt-1 text-xs">{variantSubtitle}</p>
) : null}
<p className="text-on-surface mt-2 text-sm">
{onSale ? (
<span className="text-sale font-medium">{formatPrice(totalAmount)}</span>
) : (
formatPrice(totalAmount)
)}
{onSale && compareAt ? (
<>
{" "}
<s className="text-compare text-sm">{formatPrice(compareAt)}</s>
</>
) : null}
</p>
<form {...formProps()} className="mt-3">
<button {...register("set")} className="sr-only" tabIndex={-1} />
<input type="hidden" {...register("lineId", { value: line.id })} />
<div className="quantity-selector-outlined inline-flex items-center rounded">
<button
type="submit"
{...register("decrease")}
disabled={line.quantity <= 1}
className="button-icon h-8 w-9 rounded-s disabled:opacity-40"
aria-label={`Decrease quantity: ${productTitle}`}
>
–
</button>
<input
{...register("quantity", { value: line.quantity, interactive: true })}
className="number-reset h-8 w-12 text-center text-sm outline-none focus:outline-none"
aria-label={`Quantity: ${productTitle}`}
/>
<button
type="submit"
{...register("increase")}
className="button-icon h-8 w-9 rounded-e"
aria-label={`Increase quantity: ${productTitle}`}
>
+
</button>
</div>
</form>
</div>
<form {...formProps()}>
<input type="hidden" {...register("lineId", { value: line.id })} />
<button
type="submit"
{...register("remove")}
className="button-icon self-start rounded"
aria-label={`Remove: ${productTitle}`}
>
<img
src="/icons/icon-trash.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</form>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useCart } from "@/lib/cart";
import { CART_DRAWER_ID, openCartDrawer } from "@/lib/cart-drawer";
import { cartIconLabel, cartItemCount } from "@/lib/content";
/**
* Cart trigger — a `<button>` that opens the cart drawer via `showModal()`
* (`hydrogen-cart-drawer` skill). `onClick` calls `openCartDrawer()`, which
* calls `HTMLDialogElement.showModal()`; `aria-controls`/`aria-haspopup` keep
* the control accessible. The footer `/cart` link is the no-JS cart surface.
*/
export function CartTrigger() {
const totalQuantity = useCart((state) => state.data.totalQuantity);
const cartLabel = cartIconLabel(totalQuantity);
const countDisplay = totalQuantity > 99 ? "99+" : String(totalQuantity);
return (
<>
<button
type="button"
onClick={openCartDrawer}
aria-controls={CART_DRAWER_ID}
aria-haspopup="dialog"
className="text-on-surface focus-visible:outline-accent relative inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded bg-transparent p-0 hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97]"
aria-label={cartLabel}
>
<CartIcon count={totalQuantity} display={countDisplay} />
</button>
<span aria-live="polite" aria-atomic="true" className="sr-only">
{cartItemCount(totalQuantity)}
</span>
</>
);
}
function CartIcon({ count, display }: { count: number; display: string }) {
return (
<span
className="relative inline-flex size-5 shrink-0 items-center justify-center"
aria-hidden="true"
>
<img src="/icons/icon-cart.svg" width="20" height="20" alt="" className="size-5" />
{count > 0 ? (
<span className="bg-interactive text-interactive-text absolute end-0 top-0 flex h-5 w-5 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full text-xs font-medium">
{display}
</span>
) : null}
</span>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import { useCart } from "@/lib/cart";
/**
* Publishes `CART_VIEWED` when the `/cart` page is viewed
* (`hydrogen-analytics` / `references/react.md`). The cart payload is
* `AnalyticsCart | null`: when a compatible cart is available, include `id` +
* `updatedAt` + connection-shaped `lines`; otherwise pass `cart: null`.
*/
export function CartViewedTracker() {
const cart = useCart((state) => state.data);
useEffect(() => {
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.CART_VIEWED, {
cart: cart.id ? cart : null,
prevCart: null,
});
}, [cart]);
return null;
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import {
getFilterRemovalUrl,
getSortByValue,
serializeCollectionParams,
type AvailableFilter,
type ProductFilter,
} from "@shopify/hydrogen";
import { CollectionProvider, useCollection, useCollectionForm } from "@shopify/hydrogen/react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { ProductCard, type ProductCardData } from "@/components/ProductCard";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import { content } from "@/lib/content";
import { FilterGroup } from "@/lib/filters";
/**
* Shared collection/search browser (`hydrogen-collection-browser` /
* `references/nextjs.md`). The server page fetches the product + filter
* snapshot via `staticStorefrontClient` inside a `use cache` cache-point and
* passes it here. This client component owns the `CollectionProvider`,
* `useCollection`/`useCollectionForm`, URL sync via `useRouter`/`useSearchParams`,
* and the filter/sort/grid UI.
*
* `onChange` → `router.replace(href, { scroll: false })` + `router.refresh()` so
* the RSC payload catches up when the client URL is ahead (skill-mandated).
* Browse forms carry `method="get"` + explicit `action` (feedback Round 4 #4)
* so they submit without JS (F4).
*/
export const COLLECTION_SORT_OPTIONS = [
{ label: "Featured", value: getSortByValue("COLLECTION_DEFAULT", false) },
{ label: "Best selling", value: getSortByValue("BEST_SELLING", false) },
{ label: "Alphabetically, A-Z", value: getSortByValue("TITLE", false) },
{ label: "Alphabetically, Z-A", value: getSortByValue("TITLE", true) },
{ label: "Price, low to high", value: getSortByValue("PRICE", false) },
{ label: "Price, high to low", value: getSortByValue("PRICE", true) },
{ label: "Date, new to old", value: getSortByValue("CREATED", true) },
];
export const SEARCH_SORT_OPTIONS = [
{ label: "Relevance", value: getSortByValue("RELEVANCE", false) },
{ label: "Price, low to high", value: getSortByValue("PRICE", false) },
{ label: "Price, high to low", value: getSortByValue("PRICE", true) },
];
type PageInfo = { hasNextPage: boolean; endCursor?: string | null };
export type CollectionBrowserProps =
| {
mode: "collection";
handle: string;
collection: {
id: string;
handle: string;
title: string;
description?: string | null;
descriptionHtml?: string | null;
};
products: ProductCardData[];
availableFilters: AvailableFilter[];
pageInfo: PageInfo;
dataSearch: string;
}
| {
mode: "search";
term: string;
products: ProductCardData[];
availableFilters: AvailableFilter[];
pageInfo: PageInfo;
totalCount: number;
dataSearch: string;
};
export function CollectionBrowser(props: CollectionBrowserProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlSearch = searchParams.toString();
const providerHandle = props.mode === "collection" ? props.handle : `search:${props.term}`;
return (
<CollectionProvider
data={{ handle: providerHandle, dataSearch: props.dataSearch }}
urlSearch={urlSearch}
onChange={(search) => {
const href = `${pathname}${search}`;
if (urlSearch) router.replace(href, { scroll: false });
else router.push(href, { scroll: false });
router.refresh();
}}
>
{props.mode === "collection" ? (
<CollectionViewedTracker id={props.collection.id} handle={props.collection.handle} />
) : (
<SearchViewedTracker term={props.term} totalCount={props.totalCount} />
)}
{props.mode === "collection" ? <CollectionPage {...props} /> : <SearchPage {...props} />}
</CollectionProvider>
);
}
function CollectionViewedTracker({ id, handle }: { id: string; handle: string }) {
useEffect(() => {
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.COLLECTION_VIEWED, {
collection: { id, handle },
});
}, [id, handle]);
return null;
}
function SearchViewedTracker({ term, totalCount }: { term: string; totalCount: number }) {
useEffect(() => {
if (!term) return;
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.SEARCH_VIEWED, {
searchTerm: term,
searchResults: { totalCount },
});
}, [term, totalCount]);
return null;
}
type CollectionPageProps = Extract<CollectionBrowserProps, { mode: "collection" }>;
function CollectionPage(props: CollectionPageProps) {
const { collection, products, availableFilters, pageInfo } = props;
const state = useCollection();
const { formProps } = useCollectionForm();
const router = useRouter();
// Reset key for the uncontrolled filter subtree (checkboxes + price inputs).
// Keyed by the serialized filter state (NOT the URL) so the subtree remounts
// *after* the reconciler settles `state.filters` — clearing `defaultChecked` /
// `defaultValue` when an external navigation (chip removal, clear-all) empties
// the filters. Keying by the live URL is racy: the URL clears before
// `state.filters` settles, so the remount would bake in the stale checked
// state (hydrogen-collection-browser/references/nextjs.md reset-key guidance).
const filterSubtreeKey = serializeCollectionParams({
filters: state.filters,
sortKey: undefined,
reverse: false,
}).toString();
const isLoading = state.status === "loading";
const collectionPath = `/collections/${collection.handle}`;
// `<Link>` navigations (active-filter chips, clear-all, load-more) update the
// URL but, under Cache Components / PPR, the RSC payload doesn't re-fetch on a
// same-segment `searchParams` change unless we explicitly refresh. `onChange`
// (form-driven) already calls `router.refresh()`; this wires the same refresh
// to the `<Link>` clicks so the server products/filters catch up to the URL
// (hydrogen-collection-browser/references/nextjs.md).
const onNavigate = () => router.refresh();
const showingCount = content.collection.showingCount
.replace("{{ shown }}", String(products.length))
.replace("{{ total }}", String(products.length));
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs
items={[{ label: "Collections", href: "/collections" }, { label: collection.title }]}
/>
</div>
<h1 className="type-display mb-4">{collection.title}</h1>
{collection.descriptionHtml ? (
<div
className="richtext type-body text-on-surface-secondary mb-6 max-w-prose"
dangerouslySetInnerHTML={{ __html: collection.descriptionHtml }}
/>
) : collection.description ? (
<p className="type-body text-on-surface-secondary mb-6 max-w-prose">
{collection.description}
</p>
) : null}
<form
{...formProps()}
method="get"
action={collectionPath}
className="lg:grid lg:grid-cols-[240px_1fr] lg:gap-8"
>
<FilterSidebar
key={filterSubtreeKey}
availableFilters={availableFilters}
activeFilters={state.filters}
disabled={isLoading}
/>
<div key="results" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<p className="type-body-sm text-on-surface-secondary" aria-live="polite">
{showingCount}
</p>
<SortSelect isLoading={isLoading} options={COLLECTION_SORT_OPTIONS} />
</div>
<ActiveFilterChips
activeFilters={state.filters}
collectionPath={collectionPath}
onNavigate={onNavigate}
/>
{products.length === 0 ? (
<p className="text-on-surface-secondary py-12 text-center">
{content.collection.noProducts}
</p>
) : (
<ul
role="list"
className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-3"
>
{products.map((product, index) => (
<li key={product.id} className={isLoading ? "opacity-60" : ""}>
<ProductCard
product={product}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
)}
<LoadMore pageInfo={pageInfo} collectionPath={collectionPath} onNavigate={onNavigate} />
{/* No-JS submit so the GET filter/sort form is submittable without JS (F4). */}
<noscript>
<button type="submit" className="rounded-button button-primary h-11 px-4">
{content.collection.showResults}
</button>
</noscript>
</div>
</form>
</div>
);
}
type SearchPageProps = Extract<CollectionBrowserProps, { mode: "search" }>;
function SearchPage(props: SearchPageProps) {
const { term, products, availableFilters, pageInfo, totalCount } = props;
const state = useCollection();
const { formProps } = useCollectionForm();
const router = useRouter();
const onNavigate = () => router.refresh();
// Reset key for the uncontrolled filter subtree — see CollectionPage for rationale.
const filterSubtreeKey = serializeCollectionParams({
filters: state.filters,
sortKey: undefined,
reverse: false,
}).toString();
const isLoading = state.status === "loading";
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<div className="mb-6">
<Breadcrumbs items={[{ label: content.search.title }]} />
</div>
<h1 className="type-display mb-6">{content.search.title}</h1>
{/* Search header form — real GET /search so it works without JS (F4). */}
<form action="/search" method="get" role="search" className="mb-8 flex items-center gap-2">
<label htmlFor="search-q" className="sr-only">
{content.search.label}
</label>
<input
id="search-q"
type="search"
name="q"
defaultValue={term}
placeholder={content.search.placeholder}
className="number-reset rounded-button border-border h-11 max-w-md border px-3 text-sm"
/>
<button
type="submit"
className="rounded-button button-primary inline-flex h-11 items-center justify-center px-4 text-sm font-medium"
>
{content.search.submit}
</button>
{term ? (
<Link
href="/search"
className="text-on-surface-secondary hover:text-on-surface text-sm no-underline"
>
{content.search.clear}
</Link>
) : null}
</form>
{!term ? null : products.length === 0 ? (
<div className="py-12 text-center">
<p className="type-body text-on-surface">
{content.search.noResults.replace("{{ terms }}", `\u201c${term}\u201d`)}
</p>
<p className="text-on-surface-secondary mt-2 text-sm">
{content.search.noResultsSuggestion}
</p>
</div>
) : (
<form
{...formProps()}
method="get"
action="/search"
className="lg:grid lg:grid-cols-[240px_1fr] lg:gap-8"
key={`search-${term}`}
>
<input key="q" type="hidden" name="q" value={term} />
<aside key={filterSubtreeKey} className="hidden flex-col gap-6 lg:flex">
{availableFilters.map((filter) => (
<FilterGroup key={filter.id} filter={filter} activeFilters={state.filters} />
))}
</aside>
<div key="results" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<p className="type-body-sm text-on-surface-secondary" aria-live="polite">
{content.search.resultsFor
.replace("{{ count }}", String(totalCount))
.replace("{{ terms }}", `\u201c${term}\u201d`)}
</p>
<SortSelect isLoading={isLoading} options={SEARCH_SORT_OPTIONS} />
</div>
<ul
role="list"
className="grid grid-cols-2 gap-x-1 gap-y-10 contain-paint lg:grid-cols-3"
>
{products.map((product, index) => (
<li key={product.id} className={isLoading ? "opacity-60" : ""}>
<ProductCard
product={product}
loading={index < 3 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
/>
</li>
))}
</ul>
{pageInfo.hasNextPage ? (
<div className="mt-8 text-center">
<Link
href={`/search?q=${encodeURIComponent(term)}&after=${encodeURIComponent(pageInfo.endCursor ?? "")}`}
onClick={onNavigate}
className="rounded-button button-outline inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline"
>
{content.search.loadMore}
</Link>
</div>
) : null}
<noscript>
<button type="submit" className="rounded-button button-primary h-11 px-4">
{content.collection.showResults}
</button>
</noscript>
</div>
</form>
)}
</div>
);
}
type SortOption = { label: string; value: string };
function SortSelect({ isLoading, options }: { isLoading: boolean; options: SortOption[] }) {
const state = useCollection();
const currentSort = useMemo(() => {
return serializeCollectionParams(state).toString();
}, [state]);
return (
<label className="flex items-center gap-2 text-sm">
<span className="text-on-surface-secondary">{content.collection.sortBy}</span>
<select
name="sort_by"
defaultValue={currentSort}
onChange={(event) => event.currentTarget.form?.requestSubmit()}
aria-busy={isLoading}
className="w-auto"
>
{options.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
function FilterSidebar({
availableFilters,
activeFilters,
disabled,
}: {
availableFilters: AvailableFilter[];
activeFilters: ProductFilter[];
disabled: boolean;
}) {
if (availableFilters.length === 0) return null;
return (
<aside className="hidden flex-col gap-6 lg:flex" aria-disabled={disabled}>
<div className="flex items-center justify-between">
<h2 className="type-heading-sm text-on-surface font-medium">
{content.collection.filters}
</h2>
</div>
{availableFilters.map((filter) => (
<FilterGroup key={filter.id} filter={filter} activeFilters={activeFilters} />
))}
</aside>
);
}
function ActiveFilterChips({
activeFilters,
collectionPath,
onNavigate,
}: {
activeFilters: ProductFilter[];
collectionPath: string;
onNavigate: () => void;
}) {
if (activeFilters.length === 0) return null;
return (
<ul role="list" className="flex flex-wrap gap-2">
{activeFilters.map((filter, index) => {
const currentParams = serializeCollectionParams({
filters: activeFilters,
sortKey: undefined,
reverse: false,
});
const removal = getFilterRemovalUrl(currentParams, filter);
const href = removal === "?" ? collectionPath : `${collectionPath}${removal}`;
return (
<li key={`${filter.toString()}-${index}`}>
<Link
href={href}
onClick={onNavigate}
className="chip-filled inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm no-underline"
>
{describeFilter(filter)}
<span aria-hidden="true">×</span>
</Link>
</li>
);
})}
<li>
<Link
href={collectionPath}
onClick={onNavigate}
className="text-accent inline-flex items-center rounded-full px-3 py-1 text-sm no-underline underline"
>
{content.collection.clearAll}
</Link>
</li>
</ul>
);
}
function describeFilter(filter: ProductFilter): string {
if (filter.available !== undefined) return filter.available ? "In stock" : "Out of stock";
if (filter.productType) return filter.productType;
if (filter.productVendor) return filter.productVendor;
if (filter.tag) return filter.tag;
if (filter.variantOption) {
const option = filter.variantOption;
return option.value ?? option.name ?? "Variant";
}
if (filter.price) {
const price = filter.price;
const hasMin = price.min != null && Number(price.min) > 0;
const hasMax = price.max != null;
if (hasMin && hasMax) return `${price.min} ${content.collection.priceTo} ${price.max}`;
if (hasMax) return `Up to ${price.max}`;
if (hasMin) return `From ${price.min}`;
return "Price";
}
return "Filter";
}
function LoadMore({
pageInfo,
collectionPath,
onNavigate,
}: {
pageInfo: PageInfo;
collectionPath: string;
onNavigate: () => void;
}) {
if (!pageInfo.hasNextPage) return null;
const cursor = pageInfo.endCursor ?? "";
const href = `${collectionPath}?after=${encodeURIComponent(cursor)}`;
return (
<div className="mt-8 text-center">
<Link
href={href}
onClick={onNavigate}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-5 text-sm font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.collection.loadMore}
</Link>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { StorefrontApi } from "@shopify/hydrogen";
import Link from "next/link";
import type { COLLECTION_CARD_QUERY } from "@/lib/fragments";
import { shopifyImageUrl, srcSetFor } from "@/lib/image";
/** The typed collection card node. */
export type CollectionCardData = NonNullable<
StorefrontApi.ResultOf<typeof COLLECTION_CARD_QUERY>["collection"]
>;
type CollectionCardProps = {
collection: CollectionCardData;
loading?: "eager" | "lazy";
fetchPriority?: "high" | "low" | "auto";
};
/**
* Shared `CollectionCard` — an overlay tile (square image, `overlay-dark`
* gradient, title-only, stretched link). Reused by the collections index and
* the home "shop by category" grid (engineering.md F13). Title-only — no
* product count (F5: the Storefront API has no cheap collection count).
*
* The `.card-link` is a direct child of the relative `.card` `<article>` so its
* `::after` stretched hit area covers the whole card, not just the caption.
* The caption is `pointer-events-none` so clicks over the title route to the
* card link. Server-rendered.
*/
export function CollectionCard({
collection,
loading = "lazy",
fetchPriority = "auto",
}: CollectionCardProps) {
const image = collection.image ?? collection.products.nodes[0]?.featuredImage;
const alt = collection.image?.altText ?? collection.title;
return (
<article className="group card relative overflow-hidden" aria-label={collection.title}>
<div className="bg-surface-secondary relative block aspect-square overflow-hidden motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.04]">
{image ? (
<img
src={shopifyImageUrl(image.url, { width: 600 })}
srcSet={srcSetFor(image.url, { width: 600 })}
alt={alt}
className="h-full w-full object-cover"
loading={loading}
{...(fetchPriority !== "auto" ? { fetchPriority: fetchPriority } : {})}
/>
) : null}
</div>
<div className="overlay-dark pointer-events-none absolute inset-0" />
<Link
href={`/collections/${collection.handle}`}
className="card-link"
aria-label={collection.title}
/>
<div className="text-interactive-text pointer-events-none absolute inset-x-0 bottom-0 z-10 p-4 text-left">
<h3 className="type-body-lg text-interactive-text">{collection.title}</h3>
</div>
</article>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { content } from "@/lib/content";
const CONSENT_STORAGE_KEY = "core-consent-choice";
type ConsentChoice = "accepted" | "declined";
/**
* Cookie / consent banner — the one deliberate JavaScript-only exception to the
* whole-site no-JS contract (`notes/consent-banner.md` + engineering.md F4
* Known-deferred). It gates nothing else: with JS disabled it does not render,
* and that is acceptable because there is no consent to capture and no
* analytics to gate. The banner's `mode: "default-banner"` analytics consent is
* handled by the analytics bus (`hydrogen-analytics`); this is the app-owned
* dismiss/persist UI layered on top.
*/
export function ConsentBanner() {
const [choice, setChoice] = useState<ConsentChoice | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
try {
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
if (stored === "accepted" || stored === "declined") {
setChoice(stored);
}
} catch {
// localStorage may be unavailable; treat as no prior choice.
}
}, []);
if (!mounted || choice !== null) return null;
const persist = (value: ConsentChoice) => {
setChoice(value);
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
} catch {
// Ignore storage failures.
}
};
return (
<div className="consent-banner" role="region" aria-label={content.consent.label}>
<p className="type-body text-on-surface">
{content.consent.message}{" "}
<a href="#" className="text-accent underline">
{content.consent.privacyPolicy}
</a>
</p>
<div className="consent-banner-actions">
<button
type="button"
onClick={() => persist("declined")}
className="rounded-button button-outline focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.consent.decline}
</button>
<button
type="button"
onClick={() => persist("accepted")}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{content.consent.acceptAll}
</button>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { content } from "@/lib/content";
const footerLinkClass =
"min-h-touch-target text-on-surface-secondary hover:text-on-surface focus-visible:outline-accent inline-flex items-center font-normal no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-colors";
/**
* Site footer — shared chrome, server-rendered. Includes the `/cart` link that
* is the drawer's reachable no-JS fallback on every page (engineering.md F4 +
* `notes/cart.md` "Without JavaScript").
*/
export function Footer() {
return (
<footer className="max-w-page px-margin mx-auto w-full">
<div className="border-border grid grid-cols-1 gap-8 border-t py-12 text-sm md:grid-cols-2 lg:grid-cols-4">
<div>
<h2 className="type-body-sm text-on-surface mb-4 font-medium">CORE</h2>
<p className="text-on-surface-secondary">&copy; 2026 CORE</p>
</div>
<nav aria-labelledby="footer-quick-links-heading">
<h2
className="type-body-sm text-on-surface mb-4 font-medium"
id="footer-quick-links-heading"
>
{content.footer.quickLinks}
</h2>
<ul role="list" className="flex flex-col gap-2">
<li>
<Link href="/collections" className={footerLinkClass}>
Collections
</Link>
</li>
<li>
<Link href="/collections/men" className={footerLinkClass}>
Men
</Link>
</li>
<li>
<Link href="/collections/women" className={footerLinkClass}>
Women
</Link>
</li>
<li>
<Link href="/collections/accessories" className={footerLinkClass}>
Accessories
</Link>
</li>
</ul>
</nav>
<nav aria-labelledby="footer-customer-care-heading">
<h2
className="type-body-sm text-on-surface mb-4 font-medium"
id="footer-customer-care-heading"
>
{content.footer.customerCare}
</h2>
<ul role="list" className="flex flex-col gap-2">
<li>
<Link href="/search" className={footerLinkClass}>
{content.footer.search}
</Link>
</li>
<li>
<Link href="/cart" className={footerLinkClass}>
{content.cart.title}
</Link>
</li>
</ul>
</nav>
<div>
<h2 className="type-body-sm text-on-surface mb-4 font-medium">
{content.footer.paymentMethods}
</h2>
<div className="flex flex-wrap items-center gap-2">
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Visa
</span>
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Mastercard
</span>
<span className="border-border text-on-surface-secondary rounded-sm border px-2 py-1 text-xs">
Shop Pay
</span>
</div>
</div>
</div>
</footer>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { content } from "@/lib/content";
import { CartTrigger } from "./CartTrigger";
import { MobileNavDialog } from "./MobileNavDialog";
import { PredictiveSearchTrigger } from "./PredictiveSearchTrigger";
/** Maps a header nav item to its route. "Collections" -> the collections
* index; the category items -> their collection PLP. */
const navItemHref: Record<(typeof content.header.navItems)[number], string> = {
Collections: "/collections",
Men: "/collections/men",
Women: "/collections/women",
Accessories: "/collections/accessories",
};
/**
* Site header — server shell + small client islands (engineering.md F7;
* `hydrogen-setup` / `references/navbar.md`). Server-rendered: logo `<Link>`,
* desktop nav `<Link>`s, and a real `<Link href="/search">` search trigger
* (server-rendered, F4 — reachable without JS). Client islands: `CartTrigger`
* (`showModal()` button, cart count), `PredictiveSearchTrigger` (hydrates
* the `/search` link into the modal trigger), `MobileNavDialog` (`<dialog>` with
* an always-rendered fallback link list).
*
* The cart trigger opens the `<dialog>` drawer via `showModal()`
* (hydrogen-cart-drawer) — NO `hasHydrated` anchor/button swap (feedback
* Round 4 #3). The footer `/cart` link is the no-JS cart fallback.
*/
export function Header({ accountLink }: { accountLink?: ReactNode }) {
return (
<header className="border-border bg-surface sticky top-0 z-40 border-b">
<div
className="max-w-page px-margin mx-auto flex h-16 w-full items-center justify-between"
data-header-nav-group
>
<div className="flex items-center gap-2">
<MobileNavDialog />
<Link
href="/"
className="text-on-surface focus-visible:outline-accent inline-flex items-center rounded-sm text-lg font-medium no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
CORE
</Link>
</div>
<nav
aria-label={content.header.navigation}
className="mx-8 hidden min-w-0 flex-1 items-center gap-8 md:flex"
>
{content.header.navItems.map((item) => (
<Link
key={item}
href={navItemHref[item]}
className="text-on-surface focus-visible:outline-accent shrink-0 rounded-sm text-sm font-normal whitespace-nowrap no-underline hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition-opacity"
>
{item}
</Link>
))}
</nav>
<div className="flex items-center gap-0">
<PredictiveSearchTrigger />
<noscript>
<form action="/search" method="get" role="search" className="sr-only">
<label htmlFor="header-search-q">{content.general.search}</label>
<input id="header-search-q" name="q" type="search" />
<button type="submit">{content.search.submit}</button>
</form>
</noscript>
{accountLink}
<CartTrigger />
</div>
</div>
</header>
);
}
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
import { isCustomerLoggedIn } from "@/lib/customer-account";
import { isCustomerAccountsAvailable } from "@/lib/storefront-config";
const accountLinkClassName =
"button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97] hover:opacity-70";
/**
* Header account link — an async Server Component resolved under `<Suspense>`
* by `AppShell`. Uses plain `<a>` (not `next/link`) for both `/account` and
* `/account/login`: `/account/login` is a Hydrogen-owned route intercepted in
* `proxy.ts` (no app route file), and a `next/link` client-side RSC navigation
* is fragile when the proxy responds to an RSC data fetch with a 303-to-
* external. A plain `<a>` forces a full-page navigation the proxy intercepts
* cleanly — the native choice for handler-intercepted paths.
*
* Gated on availability (not just `isLoggedIn`): on mock.shop the handlers
* aren't registered, so `/account/login` would 404. Hide the link entirely.
*/
export async function HeaderAccountLink() {
const available = isCustomerAccountsAvailable(); // sync — no await
if (!available) return null;
const loggedIn = await isCustomerLoggedIn();
if (loggedIn) {
return (
<a href="/account" className={accountLinkClassName} aria-label="Account">
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</a>
);
}
return (
<a href="/account/login" className={accountLinkClassName} aria-label="Log in">
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</a>
);
}
export function HeaderAccountLinkFallback() {
// Icon-sized placeholder (not the word "Account"): the resolved link renders
// a 20px user icon — or `null` on mock.shop — so showing text here would flash
// and swap to an icon. A dimmed icon matches the header's icon-button sizing.
return (
<span
role="status"
aria-live="polite"
aria-busy
aria-label="Loading account"
className="inline-flex h-11 w-11 items-center justify-center opacity-40"
>
<img
src="/icons/icon-user.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</span>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { content } from "@/lib/content";
const navItemHref: Record<(typeof content.header.navItems)[number], string> = {
Collections: "/collections",
Men: "/collections/men",
Women: "/collections/women",
Accessories: "/collections/accessories",
};
/**
* Mobile nav — a `<dialog>` opened via state, with an always-rendered fallback
* link list (F4). The hamburger button is the client trigger; the dialog
* contains the same nav items as the desktop nav. Server-rendered baseline is
* the hidden dialog content (links are real `<Link>`s).
*/
export function MobileNavDialog() {
const [open, setOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
return (
<div className="-ms-2 hidden max-md:block">
<button
type="button"
onClick={() => setOpen(true)}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.header.menu}
>
<img
src="/icons/icon-menu.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
<dialog
ref={dialogRef}
id="mobile-nav-drawer"
className="drawer-left bg-surface text-on-surface"
aria-labelledby="mobile-nav-title"
onClose={() => setOpen(false)}
>
<div className="flex h-full flex-col">
<div className="relative flex min-h-[52px] shrink-0 items-center px-4 py-1">
<span
className="text-on-surface pointer-events-none absolute left-1/2 -translate-x-1/2 text-sm font-medium"
id="mobile-nav-title"
aria-live="polite"
>
{content.header.mobileNavigation}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="button-icon focus-visible:outline-accent ms-auto inline-flex h-11 w-11 items-center justify-center rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav aria-label={content.header.mobileNavigation}>
<ul role="list" className="flex flex-col">
{content.header.navItems.map((item) => (
<li key={item}>
<Link
href={navItemHref[item]}
onClick={() => setOpen(false)}
className="text-on-surface flex items-center rounded-sm py-3 text-xl font-normal no-underline hover:opacity-70 motion-safe:transition-opacity"
>
{item}
</Link>
</li>
))}
</ul>
</nav>
</div>
</div>
</dialog>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import {
getPredictiveSearchItemUrl,
type PredictiveSearchData,
type PredictiveSearchState,
} from "@shopify/hydrogen";
import {
PredictiveSearchProvider,
usePredictiveSearch,
usePredictiveSearchActions,
usePredictiveSearchForm,
} from "@shopify/hydrogen/react";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { content } from "@/lib/content";
import { shopifyImageUrl } from "@/lib/image";
import { formatPrice } from "@/lib/money";
import { routeTemplates } from "@/lib/route-templates";
const PREDICTIVE_SEARCH_LIMIT = 5;
const PREDICTIVE_SEARCH_DIALOG_ID = "search-modal";
/**
* Predictive search modal (`hydrogen-predictive-search` skill). Opened from the
* header search trigger. The form is a native `GET /search` fallback so a no-JS
* shopper reaches full search results (F4). The modal is a centered `<dialog>`
* (`.dialog-center`). Products only, per `notes/predictive-search.md`.
*/
export function PredictiveSearchModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
return (
<PredictiveSearchProvider limit={PREDICTIVE_SEARCH_LIMIT} types={["PRODUCT"]}>
<PredictiveSearchDialogInner isOpen={isOpen} onClose={onClose} />
</PredictiveSearchProvider>
);
}
function PredictiveSearchDialogInner({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const state = usePredictiveSearch();
const { clear } = usePredictiveSearchActions();
const { formProps, register } = usePredictiveSearchForm();
// Open/close the native <dialog> and clear predictive state on close so stale
// suggestions do not reappear on the next open.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen && !dialog.open) {
dialog.showModal();
} else if (!isOpen && dialog.open) {
dialog.close();
clear();
}
}, [isOpen, clear]);
return (
<dialog
ref={dialogRef}
id={PREDICTIVE_SEARCH_DIALOG_ID}
className="dialog-center"
aria-labelledby="search-modal-title"
onClose={onClose}
>
<div className="flex h-full flex-col">
<div className="border-border flex shrink-0 items-center gap-2 border-b p-4">
<form {...formProps()} role="search" className="flex flex-1 items-center gap-2">
<label htmlFor="predictive-search-q" className="sr-only">
{content.search.label}
</label>
<input
id="predictive-search-q"
type="search"
{...register("query")}
placeholder={content.search.placeholder}
className="flex-1"
autoComplete="off"
/>
<button type="submit" className="sr-only">
{content.search.submit}
</button>
</form>
<button
type="button"
onClick={onClose}
className="button-icon inline-flex h-11 w-11 items-center justify-center rounded"
aria-label={content.general.close}
>
<img
src="/icons/icon-x.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</div>
{state.result.term || state.result.items.products.length > 0 ? (
<div className="flex-1 overflow-y-auto p-4">
<h2 id="search-modal-title" className="sr-only">
{content.search.title}
</h2>
<PredictiveBody state={state} onNavigate={onClose} />
</div>
) : (
<h2 id="search-modal-title" className="sr-only">
{content.search.title}
</h2>
)}
{state.result.items.products.length > 0 ? (
<div className="border-border shrink-0 border-t p-4">
<Link
href={`/search?q=${encodeURIComponent(state.result.term)}`}
onClick={onClose}
className="rounded-button button-secondary inline-flex h-11 w-full items-center justify-center px-4 text-sm font-medium no-underline"
>
View all results
</Link>
</div>
) : null}
</div>
</dialog>
);
}
function PredictiveBody({
state,
onNavigate,
}: {
state: PredictiveSearchState<PredictiveSearchData>;
onNavigate: () => void;
}) {
const { status, error, result } = state;
const products = result.items.products;
const hasResults = products.length > 0;
const term = result.term;
if (status === "loading") {
return <p className="text-on-surface-secondary text-sm">Searching…</p>;
}
if (error) {
return (
<p role="alert" className="text-sale text-sm">
{error}
</p>
);
}
if (status === "success" && !hasResults && term) {
return (
<div data-testid="search-modal-empty">
<p className="text-on-surface text-sm">No results for “{term}”</p>
</div>
);
}
if (!hasResults) return null;
return (
<ul role="listbox" aria-label="Products" className="flex flex-col gap-2">
{products.map((product) => {
const href = getPredictiveSearchItemUrl(product, { routes: routeTemplates, term });
const variant = product.selectedOrFirstAvailableVariant;
const image = variant?.image ?? null;
const price = variant?.price ?? null;
return (
<li key={product.id} role="option" aria-selected={false}>
<Link
href={href}
onClick={onNavigate}
className="hover:bg-hover flex items-center gap-3 rounded p-2 no-underline"
>
{image ? (
<img
src={shopifyImageUrl(image.url, { width: 64 })}
alt={image.altText ?? product.title}
className="size-12 rounded object-cover"
/>
) : null}
<span className="flex flex-col">
<span className="type-body-sm text-on-surface font-medium">{product.title}</span>
{price ? (
<span className="text-on-surface-secondary text-sm">{formatPrice(price)}</span>
) : null}
</span>
</Link>
</li>
);
})}
</ul>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { content } from "@/lib/content";
import { PredictiveSearchModal } from "./PredictiveSearchModal";
/**
* Search trigger — a real server-rendered `<Link href="/search">` baseline
* (F4: reachable without JS) that hydrates into the predictive-search modal
* trigger after hydration. Renders `PredictiveSearchModal` (client) when open.
*/
export function PredictiveSearchTrigger() {
const [searchOpen, setSearchOpen] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => setHasHydrated(true), []);
if (!hasHydrated) {
// No-JS / pre-hydration baseline: a real /search link.
return (
<Link
href="/search"
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.search}
data-testid="search-modal-trigger"
>
<img
src="/icons/icon-search.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</Link>
);
}
return (
<>
<button
type="button"
onClick={() => setSearchOpen(true)}
className="button-icon focus-visible:outline-accent inline-flex h-11 w-11 items-center justify-center rounded no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={content.general.search}
data-testid="search-modal-trigger"
>
<img
src="/icons/icon-search.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
<PredictiveSearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { StorefrontApi } from "@shopify/hydrogen";
import Link from "next/link";
import type { PRODUCT_CARD_QUERY } from "@/lib/fragments";
import { shopifyImageUrl, srcSetFor } from "@/lib/image";
import { formatPrice } from "@/lib/money";
/** The typed product card node consumed by grids across the storefront. */
export type ProductCardData = NonNullable<
StorefrontApi.ResultOf<typeof PRODUCT_CARD_QUERY>["product"]
>;
type ProductCardProps = {
product: ProductCardData;
/** Eager-load the first row on hero-less pages (PLP/search LCP). */
loading?: "eager" | "lazy";
fetchPriority?: "high" | "low" | "auto";
};
/**
* Shared `ProductCard` — image with `images[1]` hover-swap, title
* (`type-body-sm line-clamp-2`), price block (`text-sale` +
* `<s class="text-compare">` on sale), and `badge-sale`/`badge-soldout`
* positioned `absolute start-2 top-2`. Reused by home, collection, search, and
* the PDP "you may also like" strip (engineering.md F13).
*
* The whole card is a stretched link via `.card-link::after` so the title
* anchor's hit area climbs to the whole card. Server-rendered (no client
* interactivity).
*/
export function ProductCard({
product,
loading = "lazy",
fetchPriority = "auto",
}: ProductCardProps) {
const primaryImage = product.featuredImage;
const secondaryImage = product.images.nodes[1] ?? null;
const minPrice = product.priceRange.minVariantPrice;
const compareAt = product.compareAtPriceRange?.minVariantPrice ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(minPrice.amount);
const soldOut = !product.availableForSale;
return (
<article className="group card flex flex-col gap-2" aria-label={product.title}>
<div className="rounded-card relative block aspect-square overflow-hidden">
{primaryImage ? (
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.04]">
<img
src={shopifyImageUrl(primaryImage.url, { width: 600 })}
srcSet={srcSetFor(primaryImage.url, { width: 600 })}
alt={primaryImage.altText ?? product.title}
className="h-full w-full object-cover"
loading={loading}
{...(fetchPriority !== "auto" ? { fetchPriority: fetchPriority } : {})}
/>
</div>
) : (
<div className="bg-surface-secondary h-full w-full" />
)}
{secondaryImage ? (
<div className="pointer-events-none absolute inset-0 opacity-0 motion-safe:transition-opacity motion-safe:duration-300 motion-safe:group-hover:opacity-100">
<img
src={shopifyImageUrl(secondaryImage.url, { width: 600 })}
srcSet={srcSetFor(secondaryImage.url, { width: 600 })}
alt={secondaryImage.altText ?? `${product.title} alternate`}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : null}
{onSale ? (
<span className="badge-sale absolute start-2 top-2 inline-flex items-center rounded-full font-medium">
Sale
</span>
) : null}
{soldOut ? (
<span className="badge-soldout absolute start-2 top-2 inline-flex items-center rounded-full font-medium">
Sold out
</span>
) : null}
</div>
<div className="flex flex-col gap-0.5 text-left">
<h3 className="type-body-sm text-on-surface line-clamp-2 font-medium">
<Link href={`/products/${product.handle}`} className="card-link text-on-surface">
{product.title}
</Link>
</h3>
<div className="inline-flex flex-wrap items-baseline gap-2 text-sm">
<span className={onSale ? "text-sale font-medium" : "text-on-surface font-medium"}>
<span className="sr-only">{onSale ? "Sale price: " : "Price: "}</span>
{formatPrice(minPrice)}
</span>
{onSale && compareAt ? (
<s className="text-compare text-sm">
<span className="sr-only">Regular price: </span>
{formatPrice(compareAt)}
</s>
) : null}
</div>
</div>
</article>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { canAddToCart, type SelectedOption } from "@shopify/hydrogen";
import { ShopPayButton } from "@shopify/hydrogen/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ProductViewedTracker } from "@/components/ProductViewedTracker";
import { QuantityStepper } from "@/components/QuantityStepper";
import { openCartDrawer } from "@/lib/cart-drawer";
import { content } from "@/lib/content";
import { shopifyImageUrl, srcSetFor } from "@/lib/image";
import { formatPrice } from "@/lib/money";
import { ProductProvider, useProductForm } from "@/lib/product";
import type { ProductData } from "@/lib/product-query";
import { canonicalUrl, jsonLdScript } from "@/lib/site";
/**
* Interactive product details (`hydrogen-variant-form` /
* `references/nextjs.md`). Wraps the server-fetched product in `ProductProvider`;
* variant selection + add-to-cart live here (client). Same-product option values
* are GET `<Link>`s with `aria-current` (no-JS degrades to server variant
* resolution); cross-product values navigate to the other product; non-existent
* combinations are disabled `<button>`s with `aria-pressed`.
*/
export function ProductDetails({ product }: { product: ProductData }) {
const router = useRouter();
return (
<ProductProvider
product={product}
onSelect={(result) => {
const targetHandle = result.selectedVariant?.product?.handle ?? product.handle;
const next = variantUrl(
product,
result.selectedOptions,
targetHandle,
new URLSearchParams(),
);
router.replace(next, { scroll: false });
}}
>
<ProductViewedTracker product={product} />
<ProductPage product={product} />
</ProductProvider>
);
}
function ProductPage({ product }: { product: ProductData }) {
const { options, selectedVariant, formProps, register, errors } = useProductForm();
const searchParams = useSearchParams();
const addable = canAddToCart(product, options);
const addToCartProps = register("addToCart", {});
const price = selectedVariant?.price ?? product.priceRange.minVariantPrice;
const compareAt = selectedVariant?.compareAtPrice ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(price.amount);
const allGalleryImages = product.media.nodes
.map((node) => (node.__typename === "MediaImage" && node.image ? node.image : null))
.filter((image): image is NonNullable<typeof image> => image !== null);
// Reorder so the selected variant's image is first (feedback Round 3 #4 /
// Round 4 gallery). If the variant image isn't in the media set, prepend it.
const variantImage = selectedVariant?.image ?? null;
const galleryImages = (() => {
if (!variantImage) return allGalleryImages;
const matchIndex = allGalleryImages.findIndex((image) => image.url === variantImage.url);
if (matchIndex <= 0) {
return matchIndex === 0 ? allGalleryImages : [variantImage, ...allGalleryImages];
}
return [
allGalleryImages[matchIndex],
...allGalleryImages.slice(0, matchIndex),
...allGalleryImages.slice(matchIndex + 1),
];
})();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.title,
description: product.description ?? undefined,
image: galleryImages.map((image) => image.url),
offers: selectedVariant
? {
"@type": "Offer",
price: selectedVariant.price.amount,
priceCurrency: selectedVariant.price.currencyCode,
availability: selectedVariant.availableForSale
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
url: canonicalUrl(`/products/${product.handle}`),
}
: {
"@type": "AggregateOffer",
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
lowPrice: product.priceRange.minVariantPrice.amount,
highPrice: product.priceRange.maxVariantPrice.amount,
},
};
return (
<div className="max-w-page px-margin mx-auto w-full py-8">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd) }}
/>
<div className="product-grid mb-16 grid grid-cols-1 gap-6 md:gap-12">
{/* Gallery */}
<div className="grid grid-cols-2 gap-2">
{galleryImages.map((image, index) => (
<div key={image.url} className="bg-surface-secondary aspect-square overflow-hidden">
<img
src={shopifyImageUrl(image.url, { width: index === 0 ? 800 : 400 })}
srcSet={srcSetFor(image.url, { width: index === 0 ? 800 : 400 })}
alt={image.altText ?? product.title}
className="h-full w-full object-cover"
loading={index === 0 ? "eager" : "lazy"}
{...(index === 0 ? { fetchPriority: "high" } : {})}
/>
</div>
))}
</div>
{/* Info column */}
<div className="flex flex-col gap-4 md:sticky md:top-8 md:self-start">
<h1 className="type-display">{product.title}</h1>
{product.vendor ? (
<p className="text-on-surface-secondary text-sm">{product.vendor}</p>
) : null}
<div className="inline-flex flex-wrap items-baseline gap-2 text-lg">
<span className={onSale ? "text-sale font-medium" : "text-on-surface font-medium"}>
{formatPrice(price)}
</span>
{onSale && compareAt ? (
<s className="text-compare text-base">{formatPrice(compareAt)}</s>
) : null}
</div>
{selectedVariant && !selectedVariant.availableForSale ? (
<p className="text-on-surface-secondary text-sm">{content.product.soldOut}</p>
) : null}
{/* Variant options */}
<div className="flex flex-col gap-4">
{options.map((option) => (
<fieldset key={option.name} className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface font-medium">{option.name}</legend>
<div className="flex flex-wrap gap-2">
{option.values.map((value) =>
value.handle && value.handle !== product.handle ? (
// Cross-product value — navigates to the other product
// (hydrogen-variant-form combined-listings rule). Uses the
// live `useSearchParams` base so unrelated params survive.
<Link
key={value.name}
href={variantUrl(
product,
value.selectedOptions,
value.handle,
searchParams,
)}
scroll={false}
className="option-pill no-underline"
>
{value.name}
</Link>
) : value.exists ? (
// Same-product value — a real GET `<Link>` to the option
// URL so selection works without JS (the server page
// resolves the variant). Hydration enhances the same
// element via `register("optionValue", ...)`; the
// provider `onSelect` syncs the URL client-side.
// `aria-current` marks the selected link (`aria-pressed`
// is invalid on a link).
<Link
key={value.name}
href={variantUrl(
product,
value.selectedOptions,
value.handle,
searchParams,
)}
replace
scroll={false}
aria-current={value.selected ? "true" : undefined}
className="option-pill no-underline"
{...register("optionValue", {
optionName: option.name,
value: value.name,
})}
>
{value.name}
</Link>
) : (
// Non-existent combination — no valid option URL to
// degrade to, so render a disabled `<button>` with
// `aria-pressed` (hydrogen-variant-form).
<button
key={value.name}
type="button"
aria-pressed={value.selected}
disabled
className="option-pill"
>
{value.name}
</button>
),
)}
</div>
</fieldset>
))}
</div>
{/* Add to cart form — separate from variant selection (variant-form skill).
`formProps({ afterSubmit: openCartDrawer })` opens the drawer once
on a successful reply; do not double-wire it on the button. */}
<form {...formProps({ afterSubmit: openCartDrawer })} className="flex flex-col gap-3">
<input type="hidden" {...register("merchandiseId", {})} />
<div className="flex items-center gap-3">
<span className="type-body-sm text-on-surface font-medium">
{content.product.quantity}
</span>
<QuantityStepper
inputProps={register("quantity", { defaultValue: 1 })}
label={content.product.quantity}
/>
</div>
<button
{...addToCartProps}
disabled={!addable}
className="rounded-button button-primary focus-visible:outline-accent inline-flex h-11 items-center justify-center px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:transition motion-safe:active:scale-[0.97]"
>
{addable
? content.product.addToCart
: selectedVariant
? content.product.soldOut
: content.product.selectOptions}
</button>
</form>
{selectedVariant ? (
<ShopPayButton
variants={[{ id: selectedVariant.id, quantity: 1 }]}
channel="hydrogen"
disabled={!addable}
width="100%"
borderRadius="0.5rem"
/>
) : null}
{errors.userErrors.length > 0 ? (
<ul role="alert" className="text-sale text-sm">
{errors.userErrors.map((error, index) => (
<li key={index}>{error.message}</li>
))}
</ul>
) : null}
{product.descriptionHtml ? (
<div className="richtext type-body mt-4">
<h2 className="type-heading-md mb-2">{content.product.description}</h2>
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
</div>
) : product.description ? (
<div className="type-body mt-4">
<h2 className="type-heading-md mb-2">{content.product.description}</h2>
<p>{product.description}</p>
</div>
) : null}
</div>
</div>
</div>
);
}
/**
* Build a product variant URL from a handle + selected options, preserving the
* live `useSearchParams` (or any `URLSearchParams`) base so unrelated params
* like `q` or UTM aren't dropped on a cross/combined-listing link
* (`hydrogen-variant-form` / `references/nextjs.md`).
*/
function variantUrl(
product: { handle: string; options: Array<{ name: string }> },
selectedOptions: SelectedOption[],
handle = product.handle,
base: URLSearchParams | ReturnType<typeof useSearchParams> = new URLSearchParams(),
): string {
const params = new URLSearchParams(base);
for (const option of product.options) params.delete(option.name);
for (const option of selectedOptions) params.set(option.name, option.value);
const query = params.toString();
return `/products/${handle}${query ? `?${query}` : ""}`;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef } from "react";
import { AnalyticsEvent, getAnalytics } from "@/lib/analytics";
import type { ProductData } from "@/lib/product-query";
/**
* Publishes `PRODUCT_VIEWED` when the product route mounts/changes
* (`hydrogen-analytics` / `references/react.md`). Rendered inside the
* `ProductProvider` so it re-runs on product identity changes.
*/
export function ProductViewedTracker({ product }: { product: ProductData }) {
const publishedProductHandleRef = useRef<string | undefined>(undefined);
const variant = product.selectedOrFirstAvailableVariant;
const productHandle = product.handle;
const productId = product.id;
const productTitle = product.title;
const productVendor = product.vendor;
const productPrice = variant?.price.amount ?? product.priceRange.minVariantPrice.amount;
const variantId = variant?.id ?? productId;
const variantTitle = variant?.title ?? productTitle;
const variantSku = variant?.sku ?? undefined;
useEffect(() => {
if (publishedProductHandleRef.current === productHandle) return;
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.PRODUCT_VIEWED, {
products: [
{
id: productId,
title: productTitle,
price: productPrice,
vendor: productVendor,
variantId,
variantTitle,
quantity: 1,
sku: variantSku,
},
],
});
// Track route identity separately from payload deps so exhaustive-deps stays honest without duplicate publishes.
publishedProductHandleRef.current = productHandle;
}, [
productHandle,
productId,
productPrice,
productTitle,
productVendor,
variantId,
variantSku,
variantTitle,
]);
return null;
}
+78
View File
@@ -0,0 +1,78 @@
import { useRef } from "react";
/**
* Shared plus/minus quantity stepper — used by the PDP add-to-cart form and the
* cart line-item form so both surfaces share one control (per feedback: same
* picker on the product page as in the cart).
*
* Progressive enhancement: the number `<input>` is the no-JS baseline (a real,
* editable, submittable field). The `–`/`+` buttons are `type="button"` UI
* affordances that update the input value and dispatch a native `input` event
* so any framework form binding listening on the input stays in sync. With JS
* off, the buttons do nothing and the input remains fully usable.
*
* Visual: `quantity-selector-outlined` + a reduced height (`h-9`) and a wider
* number field (`w-12`) so the digits aren't cramped relative to the buttons.
*/
type QuantityStepperProps<T extends React.InputHTMLAttributes<HTMLInputElement>> = {
/** Props spread onto the number `<input>` (e.g. a form `register(...)` spread). */
inputProps: T;
/** Accessible label for the group + controls, e.g. "Quantity: Hoodie". */
label: string;
/** Minimum value (defaults to 1). */
min?: number;
/** Step size (defaults to 1). */
step?: number;
/** Class appended to the outer group. */
className?: string;
};
export function QuantityStepper<T extends React.InputHTMLAttributes<HTMLInputElement>>({
inputProps,
label,
min = 1,
step = 1,
className = "",
}: QuantityStepperProps<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const nudge = (delta: number) => {
const input = inputRef.current;
if (!input) return;
const next = Math.max(min, (Number(input.value) || min) + delta * step);
input.value = String(next);
input.dispatchEvent(new Event("input", { bubbles: true }));
};
return (
<div
className={`quantity-selector-outlined inline-flex items-center rounded ${className}`}
role="group"
aria-label={label}
>
<button
type="button"
onClick={() => nudge(-1)}
className="button-icon h-8 w-9 rounded-s"
aria-label={`Decrease ${label}`}
>
–
</button>
<input
ref={inputRef}
type="number"
{...inputProps}
className="number-reset h-8 w-12 text-center text-sm outline-none focus:outline-none"
aria-label={label}
/>
<button
type="button"
onClick={() => nudge(1)}
className="button-icon h-8 w-9 rounded-e"
aria-label={`Increase ${label}`}
>
+
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { defaultI18n, type shop as shopConfigType } from "@shared/config";
import { ShopifyScripts } from "@shopify/hydrogen/react";
import { useRouter } from "next/navigation";
import { routeTemplates } from "@/lib/route-templates";
/**
* `ShopifyScripts` client wrapper (`hydrogen-analytics` / `references/react.md`
* + `hydrogen-setup` / `references/analytics.md`). The root layout is a server
* component and cannot call `useRouter`, so `ShopifyScripts` (which needs a
* `navigate` callback) must live in a client component. Rendered once in the
* root layout with the resolved market `i18n` (single-market example →
* `defaultI18n`) and the numeric `shop` from `@shared/config`.
*/
type ShopConfig = typeof shopConfigType;
export function ShopifyScriptsClient({ shop }: { shop: ShopConfig }) {
const router = useRouter();
return (
<ShopifyScripts
i18n={defaultI18n}
shop={shop}
navigate={(url: string) => router.push(url)}
routes={routeTemplates}
/>
);
}