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,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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`);
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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"]>;
|
||||
@@ -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>();
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user