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