From f23332e42cedbaa3e4fb0ba72f3cf4d40c767499 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Wed, 29 Jul 2026 11:02:40 -0400 Subject: [PATCH] Initial commit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu --- .gitignore | 34 + README.md | 84 ++ app/account/page.tsx | 216 +++++ app/app-shell.tsx | 94 ++ app/cart/page.tsx | 45 + app/collections/[handle]/page.tsx | 128 +++ app/collections/page.tsx | 81 ++ app/error.tsx | 41 + app/global-error.tsx | 56 ++ app/globals.css | 951 +++++++++++++++++++ app/layout.tsx | 54 ++ app/not-found.tsx | 63 ++ app/page.tsx | 169 ++++ app/products/[handle]/page.tsx | 130 +++ app/providers.tsx | 53 ++ app/robots.ts | 18 + app/search/page.tsx | 120 +++ app/sitemap.ts | 56 ++ components/AnalyticsTracker.tsx | 35 + components/Breadcrumbs.tsx | 51 + components/CartAnalyticsTracker.tsx | 33 + components/CartCheckoutButton.tsx | 28 + components/CartContent.tsx | 112 +++ components/CartDrawer.tsx | 75 ++ components/CartLineItem.tsx | 132 +++ components/CartTrigger.tsx | 51 + components/CartViewedTracker.tsx | 27 + components/CollectionBrowser.tsx | 518 ++++++++++ components/CollectionCard.tsx | 62 ++ components/ConsentBanner.tsx | 73 ++ components/Footer.tsx | 90 ++ components/Header.tsx | 81 ++ components/HeaderAccountLink.tsx | 76 ++ components/MobileNavDialog.tsx | 102 ++ components/PredictiveSearchModal.tsx | 206 ++++ components/PredictiveSearchTrigger.tsx | 63 ++ components/ProductCard.tsx | 103 ++ components/ProductDetails.tsx | 297 ++++++ components/ProductViewedTracker.tsx | 59 ++ components/QuantityStepper.tsx | 78 ++ components/ShopifyScriptsClient.tsx | 29 + lib/analytics-shop.ts | 84 ++ lib/analytics.ts | 44 + lib/cart-drawer.ts | 60 ++ lib/cart-handlers.ts | 43 + lib/cart.ts | 11 + lib/content.ts | 148 +++ lib/customer-account.ts | 53 ++ lib/customer-session-handlers.ts | 9 + lib/filters.tsx | 150 +++ lib/fragments.ts | 125 +++ lib/image.ts | 61 ++ lib/markets.ts | 42 + lib/money.ts | 18 + lib/predictive-search-handlers.ts | 11 + lib/product-query.ts | 111 +++ lib/product.ts | 10 + lib/public-storefront.ts | 47 + lib/queries.ts | 159 ++++ lib/route-templates.ts | 7 + lib/site.ts | 27 + lib/storefront-config.ts | 63 ++ lib/storefront-static.ts | 35 + lib/storefront.ts | 46 + lib/url-params.ts | 20 + next.config.ts | 25 + package.json | 30 + postcss.config.mjs | 7 + proxy.ts | 91 ++ public/favicon.svg | 10 + public/icons/icon-cart.svg | 1 + public/icons/icon-chevron-down.svg | 3 + public/icons/icon-chevron-left.svg | 3 + public/icons/icon-chevron-right.svg | 3 + public/icons/icon-filter.svg | 1 + public/icons/icon-menu.svg | 4 + public/icons/icon-minus.svg | 3 + public/icons/icon-plus.svg | 4 + public/icons/icon-search.svg | 1 + public/icons/icon-trash.svg | 10 + public/icons/icon-user.svg | 1 + public/icons/icon-x.svg | 3 + shared/buyer-ip.ts | 15 + shared/config.ts | 93 ++ shared/customer-session.ts | 190 ++++ shared/private-env.ts | 40 + tsconfig.json | 51 + yarn.lock | 1200 ++++++++++++++++++++++++ 88 files changed, 7947 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/account/page.tsx create mode 100644 app/app-shell.tsx create mode 100644 app/cart/page.tsx create mode 100644 app/collections/[handle]/page.tsx create mode 100644 app/collections/page.tsx create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/products/[handle]/page.tsx create mode 100644 app/providers.tsx create mode 100644 app/robots.ts create mode 100644 app/search/page.tsx create mode 100644 app/sitemap.ts create mode 100644 components/AnalyticsTracker.tsx create mode 100644 components/Breadcrumbs.tsx create mode 100644 components/CartAnalyticsTracker.tsx create mode 100644 components/CartCheckoutButton.tsx create mode 100644 components/CartContent.tsx create mode 100644 components/CartDrawer.tsx create mode 100644 components/CartLineItem.tsx create mode 100644 components/CartTrigger.tsx create mode 100644 components/CartViewedTracker.tsx create mode 100644 components/CollectionBrowser.tsx create mode 100644 components/CollectionCard.tsx create mode 100644 components/ConsentBanner.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Header.tsx create mode 100644 components/HeaderAccountLink.tsx create mode 100644 components/MobileNavDialog.tsx create mode 100644 components/PredictiveSearchModal.tsx create mode 100644 components/PredictiveSearchTrigger.tsx create mode 100644 components/ProductCard.tsx create mode 100644 components/ProductDetails.tsx create mode 100644 components/ProductViewedTracker.tsx create mode 100644 components/QuantityStepper.tsx create mode 100644 components/ShopifyScriptsClient.tsx create mode 100644 lib/analytics-shop.ts create mode 100644 lib/analytics.ts create mode 100644 lib/cart-drawer.ts create mode 100644 lib/cart-handlers.ts create mode 100644 lib/cart.ts create mode 100644 lib/content.ts create mode 100644 lib/customer-account.ts create mode 100644 lib/customer-session-handlers.ts create mode 100644 lib/filters.tsx create mode 100644 lib/fragments.ts create mode 100644 lib/image.ts create mode 100644 lib/markets.ts create mode 100644 lib/money.ts create mode 100644 lib/predictive-search-handlers.ts create mode 100644 lib/product-query.ts create mode 100644 lib/product.ts create mode 100644 lib/public-storefront.ts create mode 100644 lib/queries.ts create mode 100644 lib/route-templates.ts create mode 100644 lib/site.ts create mode 100644 lib/storefront-config.ts create mode 100644 lib/storefront-static.ts create mode 100644 lib/storefront.ts create mode 100644 lib/url-params.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 public/favicon.svg create mode 100644 public/icons/icon-cart.svg create mode 100644 public/icons/icon-chevron-down.svg create mode 100644 public/icons/icon-chevron-left.svg create mode 100644 public/icons/icon-chevron-right.svg create mode 100644 public/icons/icon-filter.svg create mode 100644 public/icons/icon-menu.svg create mode 100644 public/icons/icon-minus.svg create mode 100644 public/icons/icon-plus.svg create mode 100644 public/icons/icon-search.svg create mode 100644 public/icons/icon-trash.svg create mode 100644 public/icons/icon-user.svg create mode 100644 public/icons/icon-x.svg create mode 100644 shared/buyer-ip.ts create mode 100644 shared/config.ts create mode 100644 shared/customer-session.ts create mode 100644 shared/private-env.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e17c26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies +/node_modules + +# yarn +.yarn/install-state.gz + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +next-env.d.ts + +# local CDN asset symlinks into workspace package dist folders +/public/storefront/analytics/shopify.js +/public/storefront/analytics/shopify.js.map +/public/storefront/webmcp.js +/public/storefront/webmcp.js.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..2958e4e --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Hydrogen — Next.js (App Router) example + +A brand-new Next.js 16 (App Router, Turbopack, React 19.2) storefront example, +translating `examples/core` idiomatically into Next.js and bound to +`@shopify/hydrogen`. Runs on Vercel. Zero secrets required (mock.shop fallback). + +## Scripts + +- `pnpm --filter @shopify/hydrogen-example-nextjs dev` — dev server (Turbopack). +- `pnpm --filter @shopify/hydrogen-example-nextjs build` — production build. +- `pnpm --filter @shopify/hydrogen-example-nextjs start` — serve the build. +- `pnpm --filter @shopify/hydrogen-example-nextjs typecheck` — `tsc` + + `gql.tada check --fail-on-warn`. + +## Architecture + +- **Request lifecycle:** `proxy.ts` (`handleShopifyRoutes` pre-routing + + forwarded headers + mock.shop fallback) + `app/not-found.tsx` + (`handleShopifyRedirects` post-404). +- **Storefront client:** `getStorefrontClient()` (per-buyer, cart seed only) + + `staticStorefrontClient` (shared rate-limit, all catalog reads) — F2. A + browser-safe `publicStorefrontClient` (`lib/public-storefront.ts`) is provided + for future client-side Storefront fetches (e.g. TanStack Query); browser + predictive search currently goes through the same-origin + `/api/predictive-search` handler instead. +- **Layout:** shared code lives at the top level — `lib/` (storefront clients, + queries, fragments, cart, analytics, image, money, filters, markets) and + `components/` (Header, ProductCard, CartDrawer, …). `app/` holds only route + files. Imports use the `@/` alias (`tsconfig` `"@/*": ["./*"]`). +- **Caching:** Next-native `use cache` + `cacheLife`/`cacheTag` cache-points + keyed by serializable inputs (`cacheComponents: true`). No Oxygen LRU. +- **Cart seed (F1, F4):** root layout is a static shell wrapping an async + `AppShell` (cart seed via `Promise.race` + analytics shop) in `` — + the Cache Components idiom (static shell prerenders, per-buyer parts stream). +- **Markets:** `getMarketFromHeaders` reads `x-storefront-url`; the client + auto-injects `$country`/`$language` (never passed in query variables). +- **No-JS (F4):** variant GET-links switch variants server-side; cart reachable + via footer `/cart`; filter forms `method="get"` + explicit `action`. + +## mock.shop fallback + +When no `PRIVATE_STOREFRONT_API_TOKEN` is present, the example falls back to +`mock.shop` + `mock-private-token` so it runs with zero secrets. Decrypt +secrets (`pnpm examples:secrets:decrypt`) to hit a real store. + +## Customer Accounts (local HTTPS + real store) + +Customer Accounts require an HTTPS origin (Shopify OAuth rejects `http`) and a +real store (mock.shop has no Customer Account API). + +One-time setup: + +1. `pnpm https:setup` (repo root) — trusts `mkcert` and creates the + `.cert/localtest.me*` certificates. +2. `pnpm examples:secrets:decrypt` — provisions `PRIVATE_STOREFRONT_API_TOKEN` + so the example runs against a real store instead of mock.shop. + +Run the HTTPS dev server and open : + +``` +pnpm --filter @shopify/hydrogen-example-nextjs https:dev +``` + +The `/account` page shows your name + email. `/account/login`, +`/account/logout`, `/account/refresh`, and `/account/authorize` are +Hydrogen-owned routes intercepted in `proxy.ts` (no app route files exist for +them) — Customer Account OAuth login/refresh/logout is handled there. The +header account link is hidden on mock.shop and shown only when a real store is +configured. + +## Build note: `--debug-prerender` + +The `build` script uses `next build --debug-prerender`. Next.js 16 + +React 19.2 has a confirmed framework bug +([vercel/next.js#84994](https://github.com/vercel/next.js/issues/84994), +[#86178](https://github.com/vercel/next.js/issues/86178), +[#94667](https://github.com/vercel/next.js/discussions/94667)) where the +internal `/_global-error` route fails to prerender with +`TypeError: Cannot read properties of null (reading 'useContext')`, blocking +`next build`. The bug reproduces with no custom error page and is independent +of this app's code; the fix requires React 19.3.0 (unreleased). The +`--debug-prerender` flag is the only available workaround and produces a +complete, valid Partial-Prerender production build (`next start` serves it +correctly). Remove the flag once React 19.3.0 ships. diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..d057c3d --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,216 @@ +import "server-only"; +import { customerAccountConfig } from "@shared/config"; +import { createCustomerAccountClient, gql } from "@shopify/hydrogen/customer-account"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { getCustomerAccessToken } from "@/lib/customer-account"; +import { isCustomerAccountsAvailable } from "@/lib/storefront-config"; +import { toURLSearchParams } from "@/lib/url-params"; + +export const metadata: Metadata = { + title: "Account", +}; + +const CUSTOMER_QUERY = gql(` + query CurrentCustomer { + customer { + firstName + lastName + emailAddress { + emailAddress + } + } + } +`); + +type AccountCustomer = { + firstName?: string | null; + lastName?: string | null; + emailAddress?: { emailAddress?: string | null } | null; +}; + +type AccountPageProps = { + searchParams: Promise>; +}; + +/** + * `/account` — core Customer Accounts surface (name + email only). + * + * No `export const dynamic` / `export const fetchCache`: under + * `cacheComponents: true`, `force-dynamic` is not allowed. The page relies on + * the `AppShell` dynamic context (`await connection()` in the root layout's + * shell), which opts the whole subtree into dynamic rendering — any + * `headers()`/`cookies()` read inside (e.g. `getCustomerAccessToken()` → + * `createCurrentRequest()` → `headers()`) is then automatically dynamic, the + * same convention `cart/page.tsx` follows. + * + * Token refresh is delegated to the `/account/refresh` handler (intercepted in + * `proxy.ts`): the page only **reads** the token via `getAccessToken` + * (read-only, no mutation, no render-time commit — the proxy runs before RSC + * render and cannot commit mutations that occur during render). When no usable + * token is present, the page `redirect()`s to `/account/refresh?return_to=…`; + * the handler refreshes, commits the cookie on its response, and redirects + * back to `/account`, which now reads a valid token. + */ +export default async function AccountPage({ searchParams }: AccountPageProps) { + const available = isCustomerAccountsAvailable(); // sync — no await + const urlSearchParams = toURLSearchParams(await searchParams); + const loginFailed = urlSearchParams.get("login") === "failed"; + const refreshAttempted = urlSearchParams.get("refreshed") === "1"; + + if (!available) return ; + + const { accessToken, requestContext } = await getCustomerAccessToken(); + if (!accessToken && !loginFailed && !refreshAttempted) { + // Delegate the refresh to the `/account/refresh` handler instead of calling + // `getOrRefreshAccessToken` here: this page only *reads* the token during + // RSC render, and the proxy runs before render — it cannot commit a cookie + // mutation that happens during render. Refreshing in-page would break that + // no-render-commit invariant. The handler refreshes, sets the cookie on its + // own response, and redirects back with `refreshed=1`. + redirect(`/account/refresh?return_to=${encodeURIComponent("/account?refreshed=1")}`); + } + if (!accessToken) return ; + + // Build the client per-call (Next has no RR "context" equivalent; per-call + // construction from `headers()` is the native Next approach, matching + // `getStorefrontClient`). + const customerAccount = createCustomerAccountClient({ + shopId: customerAccountConfig.shopId, + requestContext, + }); + + // Keep the try/catch so a network/timeout failure surfaces as a friendly + // error card instead of an unhandled 500 in RSC render. + let customer: AccountCustomer | undefined; + let error: string | undefined; + try { + const { data, errors } = await customerAccount.graphql(CUSTOMER_QUERY, { accessToken }); + customer = errors ? undefined : data.customer; + error = errors?.[0]?.message; + } catch { + error = "Customer Account API request failed. Try again later."; + } + + return ; +} + +type AccountShellProps = + | { notice: "real-store" } + | { loginFailed: boolean } + | { customer?: AccountCustomer | null; error?: string }; + +function AccountShell(props: AccountShellProps) { + return ( +
+

+ Account +

+

+ Sign in with Shopify Customer Accounts to view your basic account identity. +

+ + {"notice" in props && } + {"loginFailed" in props && } + {"customer" in props && + (props.error ? ( + + ) : ( + + ))} +
+ ); +} + +function RealStoreNotice() { + return ( +
+

+ Customer Accounts require a real store +

+

+ This example is running against mock.shop, which has no Customer Account API. + Set your store credentials in .env.local and run{" "} + yarn https:dev to enable login. +

+
+ ); +} + +function LoginPanel({ loginFailed }: { loginFailed: boolean }) { + return ( +
+

+ Sign in +

+

+ Use your customer account to view your name and email for this store. +

+ {loginFailed ? ( +

+ We could not complete your login. Try signing in again. +

+ ) : null} + {/* Plain `` — `/account/login` is handler-intercepted in `proxy.ts`. */} + + Log in + +
+ ); +} + +function CustomerAccountError({ message }: { message: string }) { + return ( +

+ {message} +

+ ); +} + +function CustomerCard({ customer }: { customer?: AccountCustomer | null }) { + const name = [customer?.firstName, customer?.lastName].filter(Boolean).join(" ") || "Customer"; + + return ( +
+

+ Customer identity +

+

{name}

+ {customer?.emailAddress?.emailAddress ? ( +

{customer.emailAddress.emailAddress}

+ ) : null} +
+ +
+
+ ); +} diff --git a/app/app-shell.tsx b/app/app-shell.tsx new file mode 100644 index 0000000..0d1bccd --- /dev/null +++ b/app/app-shell.tsx @@ -0,0 +1,94 @@ +import "server-only"; +import { analyticsConsent as analyticsConsentConfig } from "@shared/config"; +import { connection } from "next/server"; +import { Suspense } from "react"; + +import { CartDrawer } from "@/components/CartDrawer"; +import { ConsentBanner } from "@/components/ConsentBanner"; +import { Footer } from "@/components/Footer"; +import { Header } from "@/components/Header"; +import { HeaderAccountLink, HeaderAccountLinkFallback } from "@/components/HeaderAccountLink"; +import { ShopifyScriptsClient } from "@/components/ShopifyScriptsClient"; +import { getAnalyticsShop, getScriptsShop } from "@/lib/analytics-shop"; +import { cartHandlers } from "@/lib/cart-handlers"; +import { getStorefrontClient } from "@/lib/storefront"; + +import { Providers } from "./providers"; + +/** + * Async server shell that owns the per-request (dynamic) reads: the cart seed + * + the shop analytics GID. With `cacheComponents: true`, uncached/dynamic data + * accessed in a Server Component must sit inside a `` boundary so the + * static HTML shell prerenders and the per-buyer parts stream + * (`next/server` `connection()` + `headers()`/`cookies()` are per-request). + * + * Rendered inside `` from the root layout. `await connection()` + * opts the subtree into dynamic rendering (resolves immediately on a real + * request, never during prerender). + */ +export async function AppShell({ children }: { children: React.ReactNode }) { + await connection(); + + // Cart seed: per-buyer `getStorefrontClient()` (skill-mandated; the cart is + // personalized). Non-blocking via `Promise.race` (F1). On success pass the + // full `{cart, errors?}` envelope; on timeout/error pass `undefined` so the + // client fetches `/api/cart` after hydration (NOT `{cart:null}` — that would + // suppress the retry). + const storefrontClient = await getStorefrontClient(); + let cartData: Awaited>["data"] | undefined; + try { + const result = await Promise.race([ + cartHandlers.get({ storefrontClient }), + timeoutReject(2000), + ]); + cartData = result.data; + } catch (error) { + console.error("[hydrogen] Cart seed failed or timed out", error); + cartData = undefined; + } + + // Analytics shop GID: best-effort, non-blocking (F1). Merged with + // `@shared/config` metadata (acceptedLanguage/currency/hydrogenSubchannelId). + const analyticsShop = await getAnalyticsShop(); + const scriptsShop = getScriptsShop(); + + return ( + + + Skip to content + + +
}> + + + } + /> + +
+ {children} +
+ +