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
+84
View File
@@ -0,0 +1,84 @@
import "server-only";
import { analyticsShop as analyticsShopConfig, shop as shopConfig } from "@shared/config";
import { cacheLife, cacheTag } from "next/cache";
import { SHOP_ANALYTICS_QUERY } from "@/lib/queries";
import { staticStorefrontClient } from "@/lib/storefront-static";
/**
* Resolve the shop analytics GID best-effort + non-blocking (engineering.md F1).
* The query runs inside a `'use cache'` cache-point (`cacheLife("hours")`,
* `cacheTag("shop")`) so warm requests resolve instantly. On timeout/error we
* fall back to the config-derived shop GID/name (avoids drift — `@shared/config`
* `analyticsShop.shopId` is already `gid://shopify/Shop/${shop.shopId}`).
*/
export type AnalyticsShop = {
shopId: string;
shopName: string;
shopDescription: string | null;
};
const SHOP_FALLBACK: AnalyticsShop = {
shopId: analyticsShopConfig.shopId,
shopName: "CORE",
shopDescription: null,
};
/** Cache the shop query result for hours (it almost never changes). */
async function fetchShopAnalytics(): Promise<AnalyticsShop> {
"use cache";
cacheLife("hours");
cacheTag("shop");
const { data, errors } = await staticStorefrontClient.graphql(SHOP_ANALYTICS_QUERY);
if (errors) {
console.error("[hydrogen] Root shop query failed", errors);
}
return {
shopId: data?.shop?.id ?? SHOP_FALLBACK.shopId,
shopName: data?.shop?.name ?? SHOP_FALLBACK.shopName,
shopDescription: data?.shop?.description ?? null,
};
}
/**
* Best-effort, non-blocking shop analytics resolution. Races the cached query
* against a 2000ms timeout; on timeout/error falls back to `@shared/config`.
* Merges the resolved GID/name with the config-derived `analyticsShop`
* metadata (acceptedLanguage/currency/hydrogenSubchannelId).
*/
export async function getAnalyticsShop(): Promise<{
shopId: string;
acceptedLanguage: string;
currency: string;
hydrogenSubchannelId: string;
shopName: string;
shopDescription: string | null;
}> {
let resolved = SHOP_FALLBACK;
try {
resolved = await Promise.race([fetchShopAnalytics(), timeoutReject<AnalyticsShop>(2000)]);
} catch (error) {
console.error("[hydrogen] Root shop query failed or timed out", error);
}
return {
shopId: resolved.shopId,
acceptedLanguage: analyticsShopConfig.acceptedLanguage,
currency: analyticsShopConfig.currency,
hydrogenSubchannelId: analyticsShopConfig.hydrogenSubchannelId,
shopName: resolved.shopName,
shopDescription: resolved.shopDescription,
};
}
/** Merge with the config-derived shop for `ShopifyScripts` (numeric shopId). */
export function getScriptsShop() {
return shopConfig;
}
function timeoutReject<T>(ms: number): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`shop query timed out after ${ms}ms`)), ms);
});
}
+44
View File
@@ -0,0 +1,44 @@
import {
createStorefrontAnalytics,
AnalyticsEvent,
type ConsentConfig,
type ShopAnalytics,
type StorefrontAnalytics,
} from "@shopify/hydrogen";
export { AnalyticsEvent };
/**
* Analytics singleton (`hydrogen-analytics` skill). One bus per page lifetime,
* lazily created on the client. SSR no-ops via the `typeof window` guard.
*
* `shop` and `consent` are resolved on the server root from `@shared/config`
* and passed into `configureAnalytics()` before any route publishes a view
* event (F9: no polling, no init race). The whole `analyticsConsent` object
* (incl. `publicStorefrontAccessToken`) is threaded so the banner/consent mode
* matches `examples/shared/config.ts`.
*/
let bus: StorefrontAnalytics | null = null;
let analyticsShop: ShopAnalytics | null = null;
let analyticsConsent: ConsentConfig | null = null;
export function configureAnalytics(shop: ShopAnalytics, consent: ConsentConfig) {
analyticsShop = shop;
analyticsConsent = consent;
}
export function getAnalyticsShop(): ShopAnalytics | null {
return analyticsShop;
}
export function getAnalytics(): StorefrontAnalytics | null {
if (typeof window === "undefined") return null; // SSR no-op
if (!analyticsShop || !analyticsConsent) return null;
if (bus) return bus;
bus = createStorefrontAnalytics({
shop: analyticsShop,
consent: analyticsConsent,
// canTrack: leave the default (Customer Privacy API) in production.
});
return bus;
}
+60
View File
@@ -0,0 +1,60 @@
export const CART_DRAWER_ID = "cart-drawer";
const STANDARD_ACTIONS_READY_EVENT = "DOMContentLoaded";
let openCartActionConfigured = false;
let openCartActionRetryQueued = false;
function getCartDrawer() {
if (typeof document === "undefined") return null;
const drawer = document.getElementById(CART_DRAWER_ID);
return drawer instanceof HTMLDialogElement ? drawer : null;
}
/** Open the cart drawer (`<dialog>` + `showModal()`). */
export function openCartDrawer() {
const drawer = getCartDrawer();
if (!drawer || drawer.open) return;
drawer.showModal();
}
/** Close the cart drawer. */
export function closeCartDrawer() {
getCartDrawer()?.close();
}
function configureOpenCartActionNow() {
const openCart = typeof window !== "undefined" ? window.Shopify?.actions?.openCart : undefined;
if (!openCart) return false;
openCart.configure({
handler: async () => openCartDrawer(),
});
openCartActionConfigured = true;
return true;
}
/**
* Register the drawer's DOM helper as the `window.Shopify.actions.openCart()`
* Standard Action handler (`hydrogen-cart-drawer` skill). The module-scope call
* no-ops during SSR, configures immediately when Standard Actions is available,
* and retries once on `DOMContentLoaded` when the runtime loads after this
* module.
*/
export function configureOpenCartAction() {
if (typeof document === "undefined" || openCartActionConfigured) return;
if (configureOpenCartActionNow()) return;
if (openCartActionRetryQueued || document.readyState !== "loading") return;
openCartActionRetryQueued = true;
document.addEventListener(
STANDARD_ACTIONS_READY_EVENT,
() => {
openCartActionRetryQueued = false;
configureOpenCartAction();
},
{ once: true },
);
}
configureOpenCartAction();
+43
View File
@@ -0,0 +1,43 @@
import { createCartServerHandlers, gql } from "@shopify/hydrogen";
/**
* Custom cart fragment — adds `updatedAt` (for analytics cart-change dedupe)
* and `merchandise.price`/`product.id`/`product.vendor` (required by the
* `AnalyticsCart` shape the analytics bus consumes). Composed alongside the
* built-in `HydrogenCartFragment` by `createCartServerHandlers`.
*
* `hydrogen-analytics`: `updateCart()` keys dedupe on `updatedAt`; without it
* cart events are silently ignored. The bus's `AnalyticsCartLine` requires
* `merchandise.price` and `product.{id,vendor}`, which the default fragment
* omits.
*/
const cartFragment = gql(`
fragment CartFragment on Cart {
updatedAt
lines(first: 250) {
nodes {
merchandise {
... on ProductVariant {
price {
amount
currencyCode
}
}
}
}
}
}
`);
/**
* Cart server handlers, registered in the root middleware's
* `handleShopifyRoutes` wiring. The React cart bindings in `app/lib/cart.ts`
* are derived from these handlers' type so the cart provider's `initialData`
* envelope and line types stay in sync with the server contract.
*
* `hydrogen-request-handlers` / `references/frameworks.md` owns the wiring; the
* `hydrogen-cart-ui` React reference owns the provider/form helpers.
*/
export const cartHandlers = createCartServerHandlers({
fragment: cartFragment,
});
+11
View File
@@ -0,0 +1,11 @@
import { createCartComponents } from "@shopify/hydrogen/react";
import type { cartHandlers } from "./cart-handlers";
/**
* React cart bindings derived from the cart server handlers' type. The
* `CartProvider` accepts the full handler data envelope (`{cart, errors?}`) as
* `initialData` — see `hydrogen-cart-ui` / `references/react.md`. Do not unwrap
* to `data.cart`: `{cart: null}` tells the client the server already checked.
*/
export const { CartProvider, useCart, useCartForm } = createCartComponents<typeof cartHandlers>();
+148
View File
@@ -0,0 +1,148 @@
/**
* Copy strings transcribed from `examples/core/content.json`. Centralized so
* the React Router example uses the same verified copy as the other examples.
*/
export const content = {
announcement: {
label: "Announcement",
text: "Free shipping on orders over $50",
},
general: {
skipToContent: "Skip to content",
search: "Search",
account: "Account",
back: "Back",
close: "Close",
drawer: "Drawer",
dismiss: "Dismiss",
},
header: {
menu: "Menu",
navigation: "Main navigation",
mobileNavigation: "Mobile navigation",
navItems: ["Collections", "Men", "Women", "Accessories"] as const,
},
footer: {
quickLinks: "Quick links",
customerCare: "Customer care",
search: "Search",
account: "Account",
paymentMethods: "Payment methods",
},
cart: {
title: "Cart",
checkout: "Checkout",
empty: "Your cart is empty.",
emptyDescription: "Looks like you haven't added anything to your cart yet.",
totalLabel: "Estimated total",
taxesAndShippingAtCheckout: "Taxes and shipping calculated at checkout",
itemRemoved: "Item removed from cart",
updated: "Cart updated",
updateError: "Could not update cart. Please try again.",
iconLabel: {
one: "Cart (1 item)",
other: "Cart ({{ count }} items)",
},
itemCount: {
one: "1 item in cart",
other: "{{ count }} items in cart",
},
},
product: {
details: "Product details",
quantity: "Quantity",
addToCart: "Add to cart",
selectOptions: "Select options",
addedToCart: "Added to cart",
soldOut: "Out of stock",
description: "Description",
relatedProducts: "You may also like",
badge: {
soldOut: "Sold out",
sale: "Sale",
},
inventory: {
inStock: "In stock",
lowStock: "Low stock",
lowStockCount: "Only {{ count }} left in stock",
outOfStock: "Out of stock",
},
},
collection: {
title: "Outerwear",
description:
"Layering pieces built for shifting weather — midweight overshirts, field jackets, and knitwear cut from durable natural fibers.",
productsCount: {
one: "1 product",
other: "{{ count }} products",
},
sortBy: "Sort by",
filters: "Filters",
filter: "Filter",
showResults: "Show results",
clearAll: "Clear all",
activeFilters: "Active filters",
removeFilter: "Remove {{ filter }} filter",
priceMin: "Min",
priceMax: "Max",
priceTo: "to",
loadMore: "Load more",
showingCount: "Showing {{ shown }} of {{ total }} products",
noProducts: "No products found.",
},
collections: {
title: "Collections",
allCollections: "All collections",
productCount: {
one: "1 product",
other: "{{ count }} products",
},
viewCollection: "View {{ title }}",
},
search: {
title: "Search",
placeholder: "Search",
label: "Search",
submit: "Search",
clear: "Clear search",
resultsFor: "{{ count }} results found for {{ terms }}",
showingCount: "Showing {{ shown }} of {{ total }} results",
loadMore: "Load more",
noResults: "No results found for {{ terms }}",
noResultsSuggestion: "Check your spelling or try a more general term.",
noResultsAnnouncement: "No results found for {{ terms }}",
},
home: {
hero: {
heading: "Discover our latest collection",
subtitle: "Explore our curated selection of premium products",
primaryCta: "Shop now",
secondaryCta: "Learn more",
},
bestSellers: "Best sellers",
shopByCategory: "Shop by category",
viewAll: "View all",
},
consent: {
label: "Cookie consent",
message: "We use cookies to improve your experience, analyze traffic, and personalize content.",
privacyPolicy: "Privacy Policy",
acceptAll: "Accept all",
decline: "Decline",
managePreferences: "Manage preferences",
},
} as const;
/** Pluralized cart icon label. */
export function cartIconLabel(count: number): string {
return count === 1
? content.cart.iconLabel.one
: content.cart.iconLabel.other.replace("{{ count }}", String(count));
}
/** Pluralized cart item-count live-region text. */
export function cartItemCount(count: number): string {
return count === 1
? content.cart.itemCount.one
: content.cart.itemCount.other.replace("{{ count }}", String(count));
}
+53
View File
@@ -0,0 +1,53 @@
import "server-only";
import { customerAccountConfig, defaultI18n } from "@shared/config";
import { EncryptedCookieCustomerSession } from "@shared/customer-session";
import { createShopifyRequestContext } from "@shopify/hydrogen";
import { createCustomerSession } from "@shopify/hydrogen/customer-account";
import { headers } from "next/headers";
import { isCustomerAccountsAvailable } from "./storefront-config";
const DEFAULT_REQUEST_ORIGIN = "https://example.com";
export const customerSession = createCustomerSession({
shopId: customerAccountConfig.shopId,
customerAccountApiClientId: customerAccountConfig.customerAccountApiClientId,
});
export async function createCustomerSessionManager(request: Request) {
return EncryptedCookieCustomerSession.init(request, customerAccountConfig.sessionSecret);
}
// Reconstruct a Request from next/headers (App Router doesn't hand you one).
export async function createCurrentRequest(pathname = "/account") {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
if (!host) {
return new Request(`${DEFAULT_REQUEST_ORIGIN}${pathname}`, { headers: requestHeaders });
}
const protocol = requestHeaders.get("x-forwarded-proto") ?? "https";
return new Request(`${protocol}://${host}${pathname}`, { headers: requestHeaders });
}
export async function isCustomerLoggedIn() {
if (!isCustomerAccountsAvailable()) return false;
const { requestContext, sessionManager } = await createCustomerRequestContext();
return customerSession.isLoggedIn(sessionManager, requestContext);
}
export async function getCustomerAccessToken() {
const { requestContext, sessionManager } = await createCustomerRequestContext();
return {
accessToken: await customerSession.getAccessToken(sessionManager, requestContext),
requestContext,
};
}
async function createCustomerRequestContext(pathname = "/account") {
const request = await createCurrentRequest(pathname);
return {
request,
requestContext: createShopifyRequestContext({ request, i18n: defaultI18n }),
sessionManager: await createCustomerSessionManager(request),
};
}
+9
View File
@@ -0,0 +1,9 @@
import { createCustomerAccountServerHandlers } from "@shopify/hydrogen/customer-account";
import { customerSession } from "./customer-account";
export const customerSessionHandlers = createCustomerAccountServerHandlers({
customerSession,
defaultPostLoginRedirectPathname: "/account",
postLogoutRedirectUri: "/",
});
+150
View File
@@ -0,0 +1,150 @@
import {
isFilterInputActive,
serializeCollectionParams,
type AvailableFilter,
type ProductFilter,
} from "@shopify/hydrogen";
import { content } from "./content";
/**
* Shared collection/search filter helpers (`hydrogen-collection-browser`).
* Extracted so the collection PLP and the search page render filters
* identically and don't fork the param-serialization + value-input logic.
*/
/** Serialize a Storefront API filter `input` string into form field entries. */
export function filterValueInputParamEntries(
input: string,
): Array<{ name: string; value: string }> {
let parsedFilter: ProductFilter;
try {
// F13: skill-sanctioned cast mirroring hydrogen-collection-browser/references/react.md
// (JSON.parse of the Storefront `FilterValue.input` JSON string).
parsedFilter = JSON.parse(input) as ProductFilter;
} catch {
return [];
}
return Array.from(
serializeCollectionParams({
filters: [parsedFilter],
sortKey: undefined,
reverse: false,
}),
([name, value]) => ({ name, value }),
);
}
/** Active price filter values (for prefilling min/max), if any. */
export function activePriceRange(activeFilters: ProductFilter[]): { min: string; max: string } {
const price = activeFilters.find((f) => f.price)?.price;
return {
min: price?.min != null ? String(price.min) : "",
max: price?.max != null ? String(price.max) : "",
};
}
/** A single checkbox filter value (LIST / BOOLEAN filter types). */
export function FilterValueInput({
filter: _filter,
value,
activeFilters,
}: {
filter: AvailableFilter;
value: { id: string; label: string; count: number; input: string };
activeFilters: ProductFilter[];
}) {
const entries = filterValueInputParamEntries(value.input);
if (entries.length !== 1) return null;
const [{ name, value: paramValue }] = entries;
return (
<label className="min-h-touch-target flex items-center gap-2 text-sm">
<input
type="checkbox"
name={name}
value={paramValue}
defaultChecked={isFilterInputActive(activeFilters, value.input)}
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="size-4"
/>
<span className="text-on-surface">{value.label}</span>
{value.count > 0 ? (
<span className="text-on-surface-secondary text-xs">({value.count})</span>
) : null}
</label>
);
}
/** A min/max price range filter (PRICE_RANGE filter type). */
export function PriceRangeFilter({
filter,
activeFilters,
}: {
filter: AvailableFilter;
activeFilters: ProductFilter[];
}) {
const { min, max } = activePriceRange(activeFilters);
return (
<fieldset className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface mb-1 font-medium">{filter.label}</legend>
<div className="flex items-center gap-2">
<label className="flex flex-1 items-center gap-1 text-sm">
<span className="text-on-surface-secondary sr-only">{content.collection.priceMin}</span>
<input
type="number"
name="filter.v.price.gte"
min={0}
defaultValue={min}
placeholder={content.collection.priceMin}
inputMode="numeric"
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="number-reset rounded-button border-border h-9 w-full border px-2 text-sm"
/>
</label>
<span className="text-on-surface-secondary text-sm">{content.collection.priceTo}</span>
<label className="flex flex-1 items-center gap-1 text-sm">
<span className="text-on-surface-secondary sr-only">{content.collection.priceMax}</span>
<input
type="number"
name="filter.v.price.lte"
min={0}
defaultValue={max}
placeholder={content.collection.priceMax}
inputMode="numeric"
onChange={(event) => event.currentTarget.form?.requestSubmit()}
className="number-reset rounded-button border-border h-9 w-full border px-2 text-sm"
/>
</label>
</div>
</fieldset>
);
}
/** A filter group: renders a PRICE_RANGE or a list of checkbox values. */
export function FilterGroup({
filter,
activeFilters,
}: {
filter: AvailableFilter;
activeFilters: ProductFilter[];
}) {
if (filter.type === "PRICE_RANGE") {
return <PriceRangeFilter filter={filter} activeFilters={activeFilters} />;
}
return (
<fieldset className="flex flex-col gap-2">
<legend className="type-body-sm text-on-surface mb-1 font-medium">{filter.label}</legend>
{filter.values.map((value) => (
<FilterValueInput
key={value.id}
filter={filter}
value={value}
activeFilters={activeFilters}
/>
))}
</fieldset>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { gql } from "@shopify/hydrogen";
/**
* Shared `ProductCard` fragment — reused by the home best-sellers grid, the
* collection/search grids, and the product page's "you may also like" strip
* (engineering.md F13, F5). Title-only cards; no per-card fan-out.
*/
export const PRODUCT_CARD_FRAGMENT = gql(`
fragment ProductCard on Product {
id
handle
title
vendor
availableForSale
featuredImage {
url
altText
width
height
}
images(first: 2) {
nodes {
url
altText
}
}
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
compareAtPriceRange {
minVariantPrice {
amount
currencyCode
}
}
}
`);
/**
* Thin query that spreads the `ProductCard` fragment so the card data type can
* be derived via `StorefrontApi.ResultOf` (fragments alone resolve to `never`).
*/
export const PRODUCT_CARD_QUERY = gql(
`query ProductCardQuery { product(handle: "") { ...ProductCard } }`,
[PRODUCT_CARD_FRAGMENT],
);
/**
* Shared collection-card fragment — used by the collections index and the home
* "shop by category" grid. Pulls a single product image as a fallback when the
* collection has no image (F5: no large fan-out for a cosmetic count).
*/
export const COLLECTION_CARD_FRAGMENT = gql(`
fragment CollectionCard on Collection {
id
handle
title
description
image {
url
altText
width
height
}
products(first: 1) {
nodes {
featuredImage {
url
altText
}
}
}
}
`);
/** Thin query that spreads the `CollectionCard` fragment for type derivation. */
export const COLLECTION_CARD_QUERY = gql(
`query CollectionCardQuery { collection(handle: "") { ...CollectionCard } }`,
[COLLECTION_CARD_FRAGMENT],
);
/**
* Variant fields fragment — one reusable shape for
* `firstSelectableVariant`, `selectedOrFirstAvailableVariant`, and
* `adjacentVariants` (`hydrogen-setup` / `references/product-page.md`). After
* option selection, `selectedVariant` can come from any of those caches and
* must still contain the fields the UI needs.
*/
export const VARIANT_FIELDS_FRAGMENT = gql(`
fragment VariantFields on ProductVariant {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
image {
url
altText
width
height
}
product {
title
handle
}
sku
}
`);
+61
View File
@@ -0,0 +1,61 @@
/**
* Shopify CDN image sizing helper (`hydrogen-image` skill).
*
* Hydrogen ships no Image component. Size Shopify CDN image URLs with this tiny
* helper and render plain `<img>`. Append CDN sizing params with
* `URL.searchParams` (never string-concat) so an existing query string is
* preserved. Only rewrite Shopify CDN hosts; pass third-party images (e.g. an
* Unsplash hero) through unchanged.
*
* CDN params: https://shopify.dev/docs/api/storefront/latest/input-objects/ImageTransformInput
*/
type ShopifyImageOptions = {
width?: number;
height?: number;
crop?: "center" | "top" | "bottom" | "left" | "right";
};
/** Shopify CDN hosts (and their subdomains) that may be rewritten. */
const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "mock.shop"];
function isShopifyImageHost(hostname: string): boolean {
return SHOPIFY_CDN_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`));
}
/**
* Append Shopify CDN sizing params to `url`. Non-Shopify hosts and unparseable
* URLs are returned unchanged. Existing query params are preserved.
*/
export function shopifyImageUrl(url: string, options: ShopifyImageOptions = {}): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return url;
}
if (!isShopifyImageHost(parsed.hostname)) return url;
if (options.width) parsed.searchParams.set("width", String(options.width));
if (options.height) parsed.searchParams.set("height", String(options.height));
if (options.crop) parsed.searchParams.set("crop", options.crop);
return parsed.toString();
}
/**
* Build a 1x/2x DPR `srcset` for a Shopify CDN image. Each descriptor is sized
* via `shopifyImageUrl`. For width-descriptor srcsets (e.g. the home hero), use
* a custom srcset string instead — `sizes` is a no-op for DPR descriptors.
*/
export function srcSetFor(url: string, options: ShopifyImageOptions): string {
const oneX = shopifyImageUrl(url, options);
const twoX = shopifyImageUrl(url, {
...options,
width: options.width ? options.width * 2 : undefined,
height: options.height ? options.height * 2 : undefined,
});
return `${oneX} 1x, ${twoX} 2x`;
}
+42
View File
@@ -0,0 +1,42 @@
import { defaultI18n } from "@shared/config";
import type { I18nConfig } from "@shopify/hydrogen";
/**
* Markets resolver (`hydrogen-markets` / `references/nextjs.md` host-based
* shape). Reads the forwarded storefront URL (`x-storefront-url`, set by
* `proxy.ts` via `requestContext.getForwardedRequestHeaders()`) and resolves a
* market from a `MARKET_BY_HOST` allowlist, defaulting to the shared config's
* `defaultI18n` (US/EN).
*
* This is a single-market example, so the allowlist is empty (everything falls
* back to the default) — but the helper is wired so multi-market is an
* extension, not a rewrite. `Market` is `I18nConfig` so the resolved value is
* directly assignable to `createShopifyRequestContext({ i18n })`.
*/
export type Market = I18nConfig;
export const DEFAULT_MARKET: Market = defaultI18n;
const MARKET_BY_HOST: Record<string, Market> = {
// Single-market example: only the default. Add host -> market mappings here
// when the storefront goes multi-market (values must be valid
// ShopifyCountryCode / ShopifyLanguageCode pairs).
};
export function getMarketFromHeaders(headers: Pick<Headers, "get">): Market {
const forwardedUrl = headers.get("x-storefront-url");
if (!forwardedUrl) return DEFAULT_MARKET;
try {
const { hostname } = new URL(forwardedUrl);
const host = hostname.toLowerCase();
return MARKET_BY_HOST[host] ?? DEFAULT_MARKET;
} catch {
return DEFAULT_MARKET;
}
}
/** The static client uses the default market (no `headers()` access). */
export function getDefaultMarket(): Market {
return DEFAULT_MARKET;
}
+18
View File
@@ -0,0 +1,18 @@
import { formatMoney, type MoneyV2 } from "@shopify/hydrogen";
/**
* App wrapper around Hydrogen's `formatMoney()` (`hydrogen-money` skill).
* Keeps locale and display options consistent. Never build money strings by
* concatenation and never compute totals client-side.
*
* This storefront is single-market (US/EN), so `en-US` is the correct locale.
* Market-aware stores would pass the active market locale instead.
*/
export function formatPrice(money: MoneyV2, locale = "en-US"): string {
return formatMoney(money, { locale }).toString();
}
/** Format a price range from min/max `MoneyV2` values. */
export function formatPriceRange(min: MoneyV2, max: MoneyV2, locale = "en-US"): string {
return formatMoney([min, max], { locale }).toString();
}
+11
View File
@@ -0,0 +1,11 @@
import { createPredictiveSearchServerHandlers } from "@shopify/hydrogen";
/**
* Predictive search server handlers, registered in the root middleware's
* `handleShopifyRoutes` wiring. The browser autocomplete endpoint is
* `/api/predictive-search` by default. Limited to products per
* `notes/predictive-search.md`.
*/
export const predictiveSearchHandlers = createPredictiveSearchServerHandlers({
types: ["PRODUCT"],
});
+111
View File
@@ -0,0 +1,111 @@
import { gql, type StorefrontApi } from "@shopify/hydrogen";
import { PRODUCT_CARD_FRAGMENT, VARIANT_FIELDS_FRAGMENT } from "./fragments";
/**
* Product detail query (`hydrogen-setup` / `references/product-page.md` +
* `hydrogen-variant-form`). Derives URL-selected options with
* `getSelectedProductOptions`, includes the variant-form encoded fields, and
* uses one reusable `VariantFields` fragment across all variant caches.
*/
export const PRODUCT_QUERY = gql(
`
query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
id
handle
title
vendor
description
descriptionHtml
requiresSellingPlan
options {
name
optionValues {
name
firstSelectableVariant {
...VariantFields
}
swatch {
color
image {
... on MediaImage {
image {
url
altText
}
}
}
}
}
}
encodedVariantExistence
encodedVariantAvailability
selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
...VariantFields
}
adjacentVariants(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
...VariantFields
}
media(first: 8) {
nodes {
__typename
id
mediaContentType
alt
... on MediaImage {
image {
url
altText
width
height
}
}
previewImage {
url
altText
width
height
}
}
}
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
}
}
`,
[VARIANT_FIELDS_FRAGMENT],
);
/** Related products query for the "you may also like" strip. */
export const RELATED_PRODUCTS_QUERY = gql(
`
query RelatedProducts($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
relatedProducts: collections(first: 1) {
nodes {
products(first: 5) {
nodes {
...ProductCard
}
}
}
}
}
}
`,
[PRODUCT_CARD_FRAGMENT],
);
/** The typed product data consumed by the React product bindings. */
export type ProductData = NonNullable<StorefrontApi.ResultOf<typeof PRODUCT_QUERY>["product"]>;
+10
View File
@@ -0,0 +1,10 @@
import { createProductComponents } from "@shopify/hydrogen/react";
import type { ProductData } from "./product-query";
/**
* React product bindings derived from the typed product query
* (`hydrogen-variant-form` / `references/react.md`). The provider owns variant
* selection state; `onSelect` is where same-product URL sync happens.
*/
export const { ProductProvider, useProductForm } = createProductComponents<ProductData>();
+47
View File
@@ -0,0 +1,47 @@
import { defaultI18n, storefrontConfig } from "@shared/config";
import { createShopifyRequestContext, createStorefrontClient } from "@shopify/hydrogen";
/**
* Browser-safe public Storefront client (`hydrogen-storefront-client` /
* `references/nextjs.md` "Public client"). Holds only the **public** Storefront
* API access token, which Shopify throttles per client IP, so each shopper gets
* their own bucket. Safe to import from Client Components — it carries no
* private token and no request-scoped state.
*
* Use it for interactive client-side Storefront fetches from Client Components
* (e.g. TanStack Query / SWR): predictive autocomplete, "load more" pagination,
* availability polling. Example:
*
* // in a "use client" component
* useQuery({
* queryKey: ["search", term],
* queryFn: () => publicStorefrontClient.graphql(SEARCH, { variables: { term } }),
* });
*
* The current example routes browser predictive search through the same-origin
* `/api/predictive-search` handler (registered in `proxy.ts`) rather than
* calling Storefront directly, so this client is provided as the sanctioned
* pattern for future client-side GraphQL — not dead code. For server-side
* (RSC/route-handler) fetches, use `getStorefrontClient()` (per-buyer) or
* `staticStorefrontClient` (shared rate limit) from `lib/storefront.ts` /
* `lib/storefront-static.ts` instead.
*
* NB: `@shared/config` only inlines public values (store domain + public token)
* and a local-dev session-secret placeholder; it is safe to bundle. Production
* apps should source the public token from a `NEXT_PUBLIC_*` env var.
*/
const requestContext = createShopifyRequestContext({
// Static request context — no `headers()`, no buyer IP. The public client is
// per-IP-throttled by Shopify, not per-buyer.
request: { headers: new Headers() },
i18n: defaultI18n,
});
export const publicStorefrontClient = createStorefrontClient({
type: "public",
requestContext,
config: {
storeDomain: storefrontConfig.storeDomain,
publicStorefrontToken: storefrontConfig.publicStorefrontToken ?? "",
},
});
+159
View File
@@ -0,0 +1,159 @@
import { gql } from "@shopify/hydrogen";
import { COLLECTION_CARD_FRAGMENT, PRODUCT_CARD_FRAGMENT } from "./fragments";
/**
* Home query — best-selling products (first 8) + featured collections (first 3).
* Used by the home page (`hydrogen-setup` / `references/home-page.md`).
*/
export const HOME_QUERY = gql(
`
query Home($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
featuredProducts: products(first: 8, sortKey: BEST_SELLING) {
nodes {
...ProductCard
}
}
featuredCollections: collections(first: 3) {
nodes {
...CollectionCard
}
}
}
`,
[PRODUCT_CARD_FRAGMENT, COLLECTION_CARD_FRAGMENT],
);
/** Collections index query (`collections(first: 24)`). */
export const COLLECTIONS_QUERY = gql(
`
query CollectionsList($first: Int!, $after: String, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
...CollectionCard
}
}
}
`,
[COLLECTION_CARD_FRAGMENT],
);
/**
* Collection PLP query. `filters` and `sortKey`/`reverse` come from
* `parseCollectionParams`. The `__typename` discipline is on `ProductCard`
* (no unions here).
*/
export const COLLECTION_QUERY = gql(
`
query Collection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collection(handle: $handle) {
id
handle
title
description
descriptionHtml
image {
url
altText
width
height
}
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
filters {
id
label
type
values {
id
label
count
input
}
}
pageInfo {
hasNextPage
endCursor
}
nodes {
...ProductCard
}
}
}
}
`,
[PRODUCT_CARD_FRAGMENT],
);
/**
* Search query. **`__typename` on `search.nodes` is required** — `search` is a
* heterogeneous union and without `__typename` gql.tada infers `never` for the
* node type and all results are dropped (feedback Round 1 + Round 2 #1).
*/
export const SEARCH_QUERY = gql(
`
query Search($query: String!, $first: Int!, $after: String, $sortKey: SearchSortKeys, $reverse: Boolean, $productFilters: [ProductFilter!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
search(query: $query, first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, productFilters: $productFilters) {
productFilters {
id
label
type
values {
id
label
count
input
}
}
pageInfo {
hasNextPage
endCursor
}
nodes {
__typename
... on Product {
...ProductCard
}
}
}
}
`,
[PRODUCT_CARD_FRAGMENT],
);
/** Shop analytics GID query (root layout, best-effort + non-blocking, F1). */
export const SHOP_ANALYTICS_QUERY = gql(`
query RootShopAnalytics {
shop {
id
name
description
}
}
`);
/** Sitemap query — all products + collections with `updatedAt`. */
export const SITEMAP_QUERY = gql(`
query Sitemap($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
products(first: 250) {
nodes {
handle
updatedAt
}
}
collections(first: 250) {
nodes {
handle
updatedAt
}
}
}
`);
+7
View File
@@ -0,0 +1,7 @@
import { createShopifyRouteTemplates } from "@shopify/hydrogen";
// Canonicalize Shopify's collection-scoped product route to the product page this example handles.
// Add entries here if this example changes to custom product, collection, page, blog, or article paths.
export const routeTemplates = createShopifyRouteTemplates({
productInCollection: "/products/:productHandle",
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Trusted site origin for SEO (engineering.md F6/F10). Comes from a
* `PUBLIC_SITE_ORIGIN` environment variable, never from attacker-influenceable
* `host` / `x-forwarded-host` request headers. Defaults to the local dev origin
* (Next.js dev port 3000) so the example works without extra config.
*/
export const SITE_ORIGIN =
typeof process !== "undefined"
? (process.env.PUBLIC_SITE_ORIGIN ?? "http://localhost:3000")
: "http://localhost:3000";
/** Build an absolute canonical URL from a path. */
export function canonicalUrl(path: string): string {
return new URL(path, SITE_ORIGIN).toString();
}
/**
* Serialize JSON-LD and escape it for safe embedding in a `<script type="application/ld+json">`
* tag (engineering.md F6). This is an app-owned helper — it is NOT a Hydrogen
* export. It escapes `<` and `</script>` so the payload cannot break out of the
* script element.
*/
export function jsonLdScript(data: object): string {
const json = JSON.stringify(data);
const escaped = json.replace(/</g, "\\u003c").replace(/<\/script>/gi, "\\u003c/script\\u003e");
return escaped;
}
+63
View File
@@ -0,0 +1,63 @@
import "server-only";
import { storefrontConfig } from "@shared/config";
import { getOptionalSharedSecret } from "@shared/private-env";
/**
* Shared Storefront config resolver with mock.shop fallback
* (`hydrogen-storefront-client` + the example's zero-secrets contract).
*
* Used by both client factories (`storefront.ts` for the per-buyer cart-seed
* client, `storefront-static.ts` for the shared-rate-limit catalog client) and
* by `proxy.ts` so the request handlers hit the same store as the RSC data
* path.
*
* When no `PRIVATE_STOREFRONT_API_TOKEN` is provisioned (local dev without the
* decrypted ejson secrets), the client falls back to the public mock.shop
* endpoint using its well-known `mock-private-token` so the example runs with
* zero secrets. With a real private token present, the configured store is used
* unchanged (an optional `PUBLIC_STORE_DOMAIN` env override lets local dev
* point at a specific store without re-deriving the shared config).
*/
export const MOCK_SHOP_DOMAIN = "mock.shop";
export const MOCK_SHOP_PRIVATE_TOKEN = "mock-private-token";
export type ResolvedStorefrontConfig = {
storeDomain: string;
privateStorefrontToken: string;
};
let mockShopFallbackWarned = false;
/** Whether Customer Accounts are enabled for the resolved storefront.
*
* Sync on purpose: `resolveStorefrontConfig` is sync, and keeping this sync
* removes the risk of a forgotten `await` producing a `Promise<boolean>`
* that's always truthy when spread into `handlers` (which would silently
* register the customer account handlers on mock.shop). Poka-yoke. */
export function isCustomerAccountsAvailable(): boolean {
const { storeDomain } = resolveStorefrontConfig();
return storeDomain !== MOCK_SHOP_DOMAIN;
}
export function resolveStorefrontConfig(): ResolvedStorefrontConfig {
const privateStorefrontToken = getOptionalSharedSecret("PRIVATE_STOREFRONT_API_TOKEN");
if (!privateStorefrontToken) {
if (!mockShopFallbackWarned) {
mockShopFallbackWarned = true;
console.warn(
`[hydrogen-example-nextjs] No PRIVATE_STOREFRONT_API_TOKEN found — ` +
`running against mock.shop (${MOCK_SHOP_DOMAIN}). Set ` +
`PRIVATE_STOREFRONT_API_TOKEN in .env.local to hit a real store.`,
);
}
return {
storeDomain: MOCK_SHOP_DOMAIN,
privateStorefrontToken: MOCK_SHOP_PRIVATE_TOKEN,
};
}
const storeDomain = process.env.PUBLIC_STORE_DOMAIN ?? storefrontConfig.storeDomain;
return { storeDomain, privateStorefrontToken };
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { createShopifyRequestContext, createStorefrontClient } from "@shopify/hydrogen";
import { DEFAULT_MARKET } from "./markets";
import { resolveStorefrontConfig } from "./storefront-config";
/**
* Shared-rate-limit private Storefront client for **all catalog reads**
* (`hydrogen-storefront-client` / `references/nextjs.md` static-pages shape +
* engineering.md F2). Module-scoped: one client for the process, no `headers()`
* → no buyer IP, shared throttle bucket. Single-market example → `DEFAULT_MARKET`.
*
* Catalog pages (home, collections index, collection PLP, product, search,
* sitemap, related products, shop analytics GID) fetch through this client.
* Only the cart seed uses the per-buyer `getStorefrontClient()`.
*
* Caching lives at the `use cache` boundary (cache-points keyed by serializable
* inputs). The `cache:` option is never passed to `graphql()` — Next native
* data cache + `cacheLife`/`cacheTag` replace the Oxygen sub-request LRU.
*/
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: DEFAULT_MARKET,
});
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
export const staticStorefrontClient = createStorefrontClient({
type: "private_no_buyer_context",
requestContext,
config: {
storeDomain,
privateStorefrontToken,
},
});
+46
View File
@@ -0,0 +1,46 @@
import "server-only";
import { getBuyerIp } from "@shared/buyer-ip";
import {
createShopifyRequestContext,
createStorefrontClient,
type RequestScopedPrivateStorefrontClient,
} from "@shopify/hydrogen";
import { headers } from "next/headers";
import { cache } from "react";
import { getMarketFromHeaders } from "./markets";
import { resolveStorefrontConfig } from "./storefront-config";
/**
* Per-buyer private Storefront client (`hydrogen-storefront-client` /
* `references/nextjs.md` dynamic-pages shape). Created inside `cache(async
* () => …)` so it is request-scoped and deduped within one RSC request. Reads
* `headers()` → dynamic render + per-buyer buyer IP + market.
*
* **Used only for the cart seed in the root layout** (skill-mandated; the cart
* is personalized). Catalog reads go through `staticStorefrontClient`
* (`storefront-static.ts`) so they share a throttle bucket and never carry a
* buyer IP (F2).
*/
export const getStorefrontClient = cache(
async (): Promise<RequestScopedPrivateStorefrontClient<{}>> => {
const requestHeaders = await headers();
const requestContext = createShopifyRequestContext({
request: { headers: requestHeaders },
i18n: getMarketFromHeaders(requestHeaders),
});
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
const buyerIp = getBuyerIp(requestHeaders);
return createStorefrontClient({
type: "private",
requestContext,
config: {
storeDomain,
privateStorefrontToken,
buyerIp,
},
});
},
);
+20
View File
@@ -0,0 +1,20 @@
/**
* Adapt a Next.js App Router `searchParams` record (`Record<string, string |
* string[] | undefined>`) into a `URLSearchParams` for Hydrogen helpers that
* expect a `URLSearchParams` (`getSelectedProductOptions`, `parseCollectionParams`).
*
* `searchParams` is a `Promise<Record<...>>` in Next 15+ — `await` it first.
*/
export function toURLSearchParams(
input: Record<string, string | string[] | undefined>,
): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(input)) {
if (Array.isArray(value)) {
for (const item of value) params.append(key, item);
} else if (value != null) {
params.set(key, value);
}
}
return params;
}