// Generated by scripts/generate-manifest.mjs. Do not edit by hand. import type { AddOnCompiled } from '../../../../../types.js' type TemplateRecord = Record type TemplateAddOn = TemplateRecord & { integrations?: Array routes?: Array } type TemplateRenderContext = { [key: string]: any packageManager: any projectName: any typescript: any tailwind: any blank: any js: any jsx: any fileRouter: any codeRouter: any routerOnly: any includeExamples: any addOnEnabled: Record addOnOption: Record addOns: Array integrations: Array routes: Array getPackageManagerAddScript: (...args: Array) => string getPackageManagerRunScript: (...args: Array) => string getPackageManagerExecuteScript: (...args: Array) => string relativePath: (...args: Array) => string integrationImportContent: (...args: Array) => string integrationImportCode: (...args: Array) => string | undefined renderTemplate: (content: string) => string ignoreFile: () => never } type TemplateRenderer = (context: TemplateRenderContext) => string | undefined function __escapeXML(value: unknown) { if (value === undefined || value === null) { return '' } return String(value).replace(/[&<>'"]/g, (character) => { switch (character) { case '&': return '&' case '<': return '<' case '>': return '>' case '"': return '"' case "'": return ''' default: return character } }) } export function getManifestTemplateKey(template: string) { let hash = 0x811c9dc5 for (let i = 0; i < template.length; i++) { hash ^= template.charCodeAt(i) hash = Math.imul(hash, 0x01000193) >>> 0 } return `${hash.toString(16).padStart(8, '0')}:${template.length}` } function __render_49e52a1d_2177(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { useEffect, useState } from 'react'\nimport { createFileRoute } from '@tanstack/react-router'\nimport * as v from 'valibot'\n\nimport { isCustomerAccountConfigured } from '#/server/shopify/env'\nimport { startLogin } from '#/server/shopify/customer.functions'\n\nconst LoginSearch = v.object({ redirect: v.optional(v.string(), '/shop/account') })\n\nexport const Route = createFileRoute('/shop/account/login')({\n validateSearch: (search) => v.parse(LoginSearch, search),\n loader: () => ({ configured: isCustomerAccountConfigured() }),\n component: LoginRoute,\n})\n\nfunction LoginRoute() {\n const { redirect } = Route.useSearch()\n const { configured } = Route.useLoaderData()\n const [error, setError] = useState(null)\n\n useEffect(() => {\n if (!configured) return\n startLogin({ data: { redirectAfter: redirect } })\n .then(({ authorizationUrl }) => {\n window.location.assign(authorizationUrl)\n })\n .catch((err) => setError(err instanceof Error ? err.message : String(err)))\n }, [configured, redirect])\n\n if (!configured) {\n return (\n
\n

Customer accounts not configured

\n

\n Set SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,{' '}\n SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID, and{' '}\n SHOPIFY_SESSION_SECRET in .env.local, then\n register the redirect URI in your Shopify admin.\n

\n
\n )\n }\n\n if (error) {\n return (\n
\n

Couldn't start sign-in

\n

{error}

\n
\n )\n }\n\n return (\n

\n Redirecting to Shopify…\n

\n )\n}\n") return __output } function __render_4f27c616_2520(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { Link, createFileRoute } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { getOrders } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/')({\n loader: () => getOrders({ data: { first: 5 } }),\n component: AccountOverview,\n})\n\nfunction AccountOverview() {\n const orders = Route.useLoaderData()\n const { customer } = Route.useRouteContext()\n\n return (\n
\n
\n

\n Welcome back\n

\n

\n {customer.firstName ?? customer.emailAddress?.emailAddress ?? 'Customer'}\n

\n
\n\n
\n
\n

Recent orders

\n \n View all\n \n
\n {orders.nodes.length === 0 ? (\n

No orders yet.

\n ) : (\n
    \n {orders.nodes.map((order) => (\n \n
    \n \n {order.name}\n \n

    \n {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'}\n

    \n
    \n \n \n ))}\n
\n )}\n
\n
\n )\n}\n") return __output } function __render_7a173ee3_2276(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { createFileRoute } from '@tanstack/react-router'\n\nimport { getAddresses } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/addresses')({\n loader: () => getAddresses(),\n component: AddressesRoute,\n})\n\nfunction AddressesRoute() {\n const { addresses, defaultAddressId } = Route.useLoaderData()\n\n if (addresses.length === 0) {\n return (\n
\n

Addresses

\n
\n No saved addresses yet. Addresses you use at checkout will appear here.\n
\n
\n )\n }\n\n return (\n
\n

Addresses

\n
    \n {addresses.map((address) => (\n \n

    \n {[address.firstName, address.lastName].filter(Boolean).join(' ')}\n {address.id === defaultAddressId && (\n \n Default\n \n )}\n

    \n

    \n {address.address1}\n {address.address2 && (\n <>\n
    \n {address.address2}\n \n )}\n
    \n {[address.city, address.zoneCode, address.zip]\n .filter(Boolean)\n .join(', ')}\n
    \n {address.territoryCode}\n

    \n {address.phoneNumber && (\n

    \n {address.phoneNumber}\n

    \n )}\n \n ))}\n
\n
\n )\n}\n") return __output } function __render_7c920d31_1166(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport {\n deleteCookie,\n getCookie,\n setCookie,\n} from '@tanstack/react-start/server'\n\nconst SESSION_COOKIE = 'shopify_customer_session'\nconst FLIGHT_COOKIE = 'shopify_oauth_state'\n\nconst sessionCookieOptions = () => ({\n path: '/' as const,\n maxAge: 60 * 60 * 24 * 30, // 30 days\n sameSite: 'lax' as const,\n httpOnly: true,\n secure: import.meta.env.PROD,\n})\n\nconst flightCookieOptions = () => ({\n path: '/' as const,\n maxAge: 60 * 10, // 10 min\n sameSite: 'lax' as const,\n httpOnly: true,\n secure: import.meta.env.PROD,\n})\n\nexport function getSessionCookie() {\n return getCookie(SESSION_COOKIE)\n}\n\nexport function setSessionCookie(value: string) {\n setCookie(SESSION_COOKIE, value, sessionCookieOptions())\n}\n\nexport function clearSessionCookie() {\n deleteCookie(SESSION_COOKIE, { path: '/' })\n}\n\nexport function getFlightCookie() {\n return getCookie(FLIGHT_COOKIE)\n}\n\nexport function setFlightCookie(value: string) {\n setCookie(FLIGHT_COOKIE, value, flightCookieOptions())\n}\n\nexport function clearFlightCookie() {\n deleteCookie(FLIGHT_COOKIE, { path: '/' })\n}\n") return __output } function __render_82c11ef7_9033(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { getCustomerEnv } from '#/server/shopify/env'\n\n/**\n * OAuth 2.1 Authorization Code + PKCE flow for the Shopify Customer Account\n * API. We're a public client (browser + Start backend), so no client secret —\n * security comes from the PKCE verifier proof.\n *\n * No usable npm client exists for Customer Account API as of 2026-05; this is\n * a hand-rolled implementation using Web APIs only (portable across Node,\n * Workers, Vercel, Netlify, Bun, Deno).\n */\n\nconst SCOPE = 'openid email customer-account-api:full'\n\ntype DiscoveryDocument = {\n authorizationEndpoint: string\n tokenEndpoint: string\n endSessionEndpoint: string\n graphqlEndpoint: string\n}\n\nlet discoveryCache: DiscoveryDocument | null = null\n\nasync function fetchDiscovery(): Promise {\n if (discoveryCache) return discoveryCache\n const env = getCustomerEnv()\n const base = `https://shopify.com/${env.SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID}`\n const [oidcRes, customerRes] = await Promise.all([\n fetch(`${base}/.well-known/openid-configuration`),\n fetch(`${base}/.well-known/customer-account-api`),\n ])\n if (!oidcRes.ok || !customerRes.ok) {\n throw new Error(\n `Customer Account discovery failed (${oidcRes.status} / ${customerRes.status}). Verify SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID.`,\n )\n }\n const oidc = (await oidcRes.json()) as {\n authorization_endpoint: string\n token_endpoint: string\n end_session_endpoint: string\n }\n const customer = (await customerRes.json()) as {\n graphql_api: { endpoint: string }\n }\n discoveryCache = {\n authorizationEndpoint: oidc.authorization_endpoint,\n tokenEndpoint: oidc.token_endpoint,\n endSessionEndpoint: oidc.end_session_endpoint,\n graphqlEndpoint: customer.graphql_api.endpoint,\n }\n return discoveryCache\n}\n\nexport async function getDiscovery() {\n return fetchDiscovery()\n}\n\n/* ─── Base64url + crypto helpers (Web Crypto only) ──────────────────────── */\n\nfunction bufferToBase64Url(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer)\n let str = ''\n for (let i = 0; i < bytes.byteLength; i++) {\n str += String.fromCharCode(bytes[i]!)\n }\n return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction base64UrlEncode(input: string): string {\n return bufferToBase64Url(new TextEncoder().encode(input).buffer as ArrayBuffer)\n}\n\nfunction base64UrlDecode(input: string): string {\n const padded = input.replace(/-/g, '+').replace(/_/g, '/')\n const padding = (4 - (padded.length % 4)) % 4\n return atob(padded + '='.repeat(padding))\n}\n\nfunction randomBytes(n: number): Uint8Array {\n const arr = new Uint8Array(n)\n crypto.getRandomValues(arr)\n return arr\n}\n\nfunction randomString(byteLength: number): string {\n return bufferToBase64Url(randomBytes(byteLength).buffer as ArrayBuffer)\n}\n\nasync function sha256(input: string): Promise {\n return crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))\n}\n\n/* ─── PKCE ──────────────────────────────────────────────────────────────── */\n\nexport type PkcePair = { codeVerifier: string; codeChallenge: string }\n\nexport async function createPkcePair(): Promise {\n const codeVerifier = randomString(48)\n const codeChallenge = bufferToBase64Url(await sha256(codeVerifier))\n return { codeVerifier, codeChallenge }\n}\n\nexport function createState(): string {\n return randomString(24)\n}\n\n/* ─── Authorize / token / logout URLs ───────────────────────────────────── */\n\nexport async function buildAuthorizationUrl(input: {\n state: string\n codeChallenge: string\n}): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const url = new URL(discovery.authorizationEndpoint)\n url.searchParams.set('client_id', env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID)\n url.searchParams.set('scope', SCOPE)\n url.searchParams.set('response_type', 'code')\n url.searchParams.set('redirect_uri', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI)\n url.searchParams.set('state', input.state)\n url.searchParams.set('code_challenge', input.codeChallenge)\n url.searchParams.set('code_challenge_method', 'S256')\n return url.toString()\n}\n\ntype TokenResponse = {\n access_token: string\n refresh_token: string\n id_token: string\n expires_in: number\n token_type: 'Bearer'\n}\n\nexport async function exchangeCodeForTokens(input: {\n code: string\n codeVerifier: string\n}): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const body = new URLSearchParams({\n grant_type: 'authorization_code',\n client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,\n code: input.code,\n redirect_uri: env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI,\n code_verifier: input.codeVerifier,\n })\n const res = await fetch(discovery.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,\n },\n body,\n })\n if (!res.ok) {\n throw new Error(\n `Token exchange failed: ${res.status} ${res.statusText} ${await res.text()}`,\n )\n }\n return (await res.json()) as TokenResponse\n}\n\nexport async function refreshTokens(refreshToken: string): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,\n refresh_token: refreshToken,\n })\n const res = await fetch(discovery.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,\n },\n body,\n })\n if (!res.ok) {\n throw new Error(`Token refresh failed: ${res.status} ${res.statusText}`)\n }\n return (await res.json()) as TokenResponse\n}\n\nexport async function buildEndSessionUrl(idToken: string): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const url = new URL(discovery.endSessionEndpoint)\n url.searchParams.set('id_token_hint', idToken)\n url.searchParams.set(\n 'post_logout_redirect_uri',\n new URL('/', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).toString(),\n )\n return url.toString()\n}\n\n/* ─── Signed session cookie helpers (HS256) ─────────────────────────────── */\n\nlet cachedHmacKey: CryptoKey | null = null\n\nasync function getHmacKey(): Promise {\n if (cachedHmacKey) return cachedHmacKey\n const env = getCustomerEnv()\n cachedHmacKey = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(env.SHOPIFY_SESSION_SECRET),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign', 'verify'],\n )\n return cachedHmacKey\n}\n\nexport type CustomerSession = {\n accessToken: string\n refreshToken: string\n idToken: string\n expiresAt: number\n}\n\nexport async function signSession(session: CustomerSession): Promise {\n const key = await getHmacKey()\n const payload = base64UrlEncode(JSON.stringify(session))\n const sig = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(payload),\n )\n return `${payload}.${bufferToBase64Url(sig)}`\n}\n\nexport async function verifySession(\n signed: string,\n): Promise {\n const [payload, sig] = signed.split('.')\n if (!payload || !sig) return null\n const key = await getHmacKey()\n const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))\n const ok = await crypto.subtle.verify(\n 'HMAC',\n key,\n sigBytes,\n new TextEncoder().encode(payload),\n )\n if (!ok) return null\n try {\n return JSON.parse(base64UrlDecode(payload)) as CustomerSession\n } catch {\n return null\n }\n}\n\nexport function buildSession(token: TokenResponse): CustomerSession {\n return {\n accessToken: token.access_token,\n refreshToken: token.refresh_token,\n idToken: token.id_token,\n expiresAt: Date.now() + token.expires_in * 1000,\n }\n}\n\nexport type OAuthFlightState = {\n state: string\n codeVerifier: string\n redirectAfter: string\n}\n\nexport async function signFlightState(\n state: OAuthFlightState,\n): Promise {\n const key = await getHmacKey()\n const payload = base64UrlEncode(JSON.stringify(state))\n const sig = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(payload),\n )\n return `${payload}.${bufferToBase64Url(sig)}`\n}\n\nexport async function verifyFlightState(\n signed: string,\n): Promise {\n const [payload, sig] = signed.split('.')\n if (!payload || !sig) return null\n const key = await getHmacKey()\n const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))\n const ok = await crypto.subtle.verify(\n 'HMAC',\n key,\n sigBytes,\n new TextEncoder().encode(payload),\n )\n if (!ok) return null\n try {\n return JSON.parse(base64UrlDecode(payload)) as OAuthFlightState\n } catch {\n return null\n }\n}\n") return __output } function __render_9bd461a9_1368(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { Link } from '@tanstack/react-router'\n\nconst ITEMS = [\n { to: '/shop/account', label: 'Overview', exact: true },\n { to: '/shop/account/orders', label: 'Orders', exact: false },\n { to: '/shop/account/addresses', label: 'Addresses', exact: false },\n] as const\n\nexport function AccountNav() {\n return (\n \n )\n}\n") return __output } function __render_9ead2ab8_1870(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { Link, createFileRoute } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { getOrders } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/orders/')({\n loader: () => getOrders({ data: { first: 50 } }),\n component: OrdersRoute,\n})\n\nfunction OrdersRoute() {\n const orders = Route.useLoaderData()\n if (orders.nodes.length === 0) {\n return (\n
\n

No orders yet.

\n
\n )\n }\n return (\n
\n

Orders

\n
    \n {orders.nodes.map((order) => (\n
  • \n
    \n \n {order.name}\n \n

    \n {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'} ·{' '}\n {order.financialStatus ?? '—'}\n

    \n
    \n \n
  • \n ))}\n
\n
\n )\n}\n") return __output } function __render_b7239c88_690(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { useQuery } from '@tanstack/react-query'\n\nimport { getCustomer } from '#/server/shopify/customer.functions'\nimport type { CustomerProfile } from '#/lib/shopify/customer-queries'\n\nexport const CUSTOMER_QUERY_KEY = ['shopify', 'customer'] as const\n\nexport function useCustomer() {\n const query = useQuery({\n queryKey: CUSTOMER_QUERY_KEY,\n queryFn: () => getCustomer(),\n staleTime: 60_000,\n })\n return {\n customer: query.data ?? null,\n isLoading: query.isLoading,\n isFetching: query.isFetching,\n isError: query.isError,\n error: query.error,\n }\n}\n") return __output } function __render_c759666f_5091(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { createServerFn } from '@tanstack/react-start'\nimport { setResponseHeaders } from '@tanstack/react-start/server'\nimport * as v from 'valibot'\n\nimport {\n customerFetch,\n getCustomerSession,\n requireCustomerSession,\n} from '#/server/shopify/customer-client'\nimport {\n clearFlightCookie,\n clearSessionCookie,\n getFlightCookie,\n setFlightCookie,\n setSessionCookie,\n} from '#/server/shopify/customer-cookies'\nimport {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n buildSession,\n createPkcePair,\n createState,\n exchangeCodeForTokens,\n signFlightState,\n signSession,\n verifyFlightState,\n} from '#/server/shopify/oauth'\nimport {\n CUSTOMER_ADDRESSES_QUERY,\n CUSTOMER_QUERY,\n ORDER_QUERY,\n ORDERS_QUERY,\n type CustomerAddress,\n type CustomerAddressesQueryResult,\n type CustomerProfile,\n type CustomerQueryResult,\n type OrderDetail,\n type OrderListItem,\n type OrderQueryResult,\n type OrdersQueryResult,\n} from '#/lib/shopify/customer-queries'\n\nfunction setPrivateHeaders() {\n setResponseHeaders(\n new Headers({ 'Cache-Control': 'private, no-store, must-revalidate' }),\n )\n}\n\nexport const startLogin = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n redirectAfter: v.optional(v.string(), '/shop/account'),\n }),\n )\n .handler(async ({ data }): Promise<{ authorizationUrl: string }> => {\n setPrivateHeaders()\n const { codeVerifier, codeChallenge } = await createPkcePair()\n const state = createState()\n const flight = await signFlightState({\n state,\n codeVerifier,\n redirectAfter: data.redirectAfter,\n })\n setFlightCookie(flight)\n const authorizationUrl = await buildAuthorizationUrl({ state, codeChallenge })\n return { authorizationUrl }\n })\n\nexport const completeLogin = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ code: v.string(), state: v.string() }))\n .handler(async ({ data }): Promise<{ redirectAfter: string }> => {\n setPrivateHeaders()\n const flightCookie = getFlightCookie()\n if (!flightCookie) throw new Error('OAuth flight cookie missing or expired.')\n const flight = await verifyFlightState(flightCookie)\n if (!flight) throw new Error('OAuth flight cookie failed verification.')\n if (flight.state !== data.state) throw new Error('OAuth state mismatch.')\n\n const tokens = await exchangeCodeForTokens({\n code: data.code,\n codeVerifier: flight.codeVerifier,\n })\n const session = buildSession(tokens)\n setSessionCookie(await signSession(session))\n clearFlightCookie()\n return { redirectAfter: flight.redirectAfter }\n })\n\nexport const logout = createServerFn({ method: 'POST' }).handler(\n async (): Promise<{ endSessionUrl: string | null }> => {\n setPrivateHeaders()\n const session = await getCustomerSession()\n clearSessionCookie()\n if (!session) return { endSessionUrl: null }\n return { endSessionUrl: await buildEndSessionUrl(session.idToken) }\n },\n)\n\nexport const getCustomer = createServerFn({ method: 'GET' }).handler(\n async (): Promise => {\n setPrivateHeaders()\n const session = await getCustomerSession()\n if (!session) return null\n const result = await customerFetch({\n query: CUSTOMER_QUERY,\n session,\n })\n return result.customer\n },\n)\n\nexport const getOrders = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n first: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 20),\n after: v.optional(v.nullable(v.string())),\n }),\n )\n .handler(\n async ({\n data,\n }): Promise<{\n nodes: Array\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n }> => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: ORDERS_QUERY,\n variables: { first: data.first, after: data.after ?? null },\n session,\n })\n return result.customer.orders\n },\n )\n\nexport const getOrder = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ id: v.string() }))\n .handler(async ({ data }): Promise => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: ORDER_QUERY,\n variables: { id: data.id },\n session,\n })\n return result.order\n })\n\nexport const getAddresses = createServerFn({ method: 'GET' }).handler(\n async (): Promise<{\n addresses: Array\n defaultAddressId: string | null\n }> => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: CUSTOMER_ADDRESSES_QUERY,\n variables: { first: 50 },\n session,\n })\n return {\n addresses: result.customer.addresses.nodes,\n defaultAddressId: result.customer.defaultAddress?.id ?? null,\n }\n },\n)\n") return __output } function __render_ca835134_2683(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport {\n buildSession,\n getDiscovery,\n refreshTokens,\n signSession,\n verifySession,\n type CustomerSession,\n} from '#/server/shopify/oauth'\nimport {\n clearSessionCookie,\n getSessionCookie,\n setSessionCookie,\n} from '#/server/shopify/customer-cookies'\n\nconst REFRESH_LEEWAY_MS = 60_000 // refresh if expiring within 60s\n\nclass CustomerAuthError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CustomerAuthError'\n }\n}\n\n/**\n * Read the current customer session, refreshing tokens if they're close to\n * expiry. Returns null when the user is not signed in.\n */\nexport async function getCustomerSession(): Promise {\n const cookie = getSessionCookie()\n if (!cookie) return null\n\n const session = await verifySession(cookie)\n if (!session) {\n clearSessionCookie()\n return null\n }\n\n if (session.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {\n return session\n }\n\n // Token expired or near-expiry — refresh.\n try {\n const refreshed = await refreshTokens(session.refreshToken)\n const next = buildSession(refreshed)\n setSessionCookie(await signSession(next))\n return next\n } catch {\n clearSessionCookie()\n return null\n }\n}\n\nexport async function requireCustomerSession(): Promise {\n const session = await getCustomerSession()\n if (!session) throw new CustomerAuthError('Not signed in')\n return session\n}\n\ntype CustomerFetchInput = {\n query: string\n variables?: TVariables\n session: CustomerSession\n}\n\ntype CustomerResponse = {\n data?: TData\n errors?: Array<{ message: string }>\n}\n\n/**\n * Authenticated GraphQL fetch against the Customer Account API. Caller must\n * pass a verified session (obtained via getCustomerSession).\n */\nexport async function customerFetch>(\n input: CustomerFetchInput,\n): Promise {\n const discovery = await getDiscovery()\n const res = await fetch(discovery.graphqlEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: input.session.accessToken,\n },\n body: JSON.stringify({ query: input.query, variables: input.variables }),\n })\n if (!res.ok) {\n throw new Error(\n `Customer Account API error: ${res.status} ${res.statusText}`,\n )\n }\n const json = (await res.json()) as CustomerResponse\n if (json.errors?.length) {\n throw new Error(json.errors.map((e) => e.message).join('\\n'))\n }\n if (!json.data) throw new Error('Customer Account API returned no data.')\n return json.data\n}\n") return __output } function __render_d83ec895_4432(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport { getOrder } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/orders/$id')({\n loader: async ({ params }) => {\n const order = await getOrder({ data: { id: decodeURIComponent(params.id) } })\n if (!order) throw notFound()\n return { order }\n },\n component: OrderDetailRoute,\n})\n\nfunction OrderDetailRoute() {\n const { order } = Route.useLoaderData()\n\n return (\n
\n
\n

{order.name}

\n

\n Placed {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'}\n

\n
\n\n
    \n {order.lineItems.nodes.map((line) => (\n
  • \n \n
    \n

    {line.title}

    \n {line.variantTitle && (\n

    \n {line.variantTitle}\n

    \n )}\n

    \n Qty {line.quantity}\n

    \n
    \n \n
  • \n ))}\n
\n\n
\n {order.subtotal && (\n
\n Subtotal\n \n
\n )}\n {order.totalShipping && (\n
\n Shipping\n \n
\n )}\n {order.totalTax && (\n
\n Tax\n \n
\n )}\n
\n Total\n \n
\n
\n\n {order.shippingAddress && (\n
\n

Shipping address

\n

\n {[order.shippingAddress.firstName, order.shippingAddress.lastName]\n .filter(Boolean)\n .join(' ')}\n
\n {order.shippingAddress.address1}\n {order.shippingAddress.address2 && (\n <>\n
\n {order.shippingAddress.address2}\n \n )}\n
\n {[\n order.shippingAddress.city,\n order.shippingAddress.zoneCode,\n order.shippingAddress.zip,\n ]\n .filter(Boolean)\n .join(', ')}\n
\n {order.shippingAddress.territoryCode}\n

\n
\n )}\n
\n )\n}\n") return __output } function __render_e9563c39_582(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { createFileRoute, redirect } from '@tanstack/react-router'\n\nimport { logout } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/logout')({\n beforeLoad: async () => {\n const { endSessionUrl } = await logout()\n if (endSessionUrl) {\n // Bounce through Shopify's end-session endpoint to clear their session too.\n throw redirect({ href: endSessionUrl })\n }\n throw redirect({ to: '/shop' })\n },\n component: () => null,\n})\n") return __output } function __render_ee45a002_1600(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport * as v from 'valibot'\n\nimport { completeLogin } from '#/server/shopify/customer.functions'\n\nconst CallbackSearch = v.object({\n code: v.optional(v.string()),\n state: v.optional(v.string()),\n error: v.optional(v.string()),\n error_description: v.optional(v.string()),\n})\n\nexport const Route = createFileRoute('/shop/account/callback')({\n validateSearch: (search) => v.parse(CallbackSearch, search),\n beforeLoad: async ({ search }) => {\n if (search.error || !search.code || !search.state) {\n return { error: search.error_description ?? search.error ?? 'Missing OAuth code/state.' }\n }\n try {\n const { redirectAfter } = await completeLogin({\n data: { code: search.code, state: search.state },\n })\n throw redirect({ to: redirectAfter || '/shop/account' })\n } catch (err) {\n if (err && typeof err === 'object' && 'isRedirect' in err) throw err\n return { error: err instanceof Error ? err.message : String(err) }\n }\n },\n loader: ({ context }) => ({ error: context.error }),\n component: CallbackRoute,\n})\n\nfunction CallbackRoute() {\n const { error } = Route.useLoaderData()\n if (error) {\n return (\n
\n

Sign-in failed

\n

{error}

\n
\n )\n }\n return null\n}\n") return __output } function __render_f8afbed3_961(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\nimport { Outlet, createFileRoute, redirect } from '@tanstack/react-router'\n\nimport { AccountNav } from '#/components/shop/account-nav'\nimport { getCustomer } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account')({\n beforeLoad: async ({ location }) => {\n const customer = await getCustomer()\n if (!customer) {\n throw redirect({\n to: '/shop/account/login',\n search: { redirect: location.href },\n })\n }\n return { customer }\n },\n loader: ({ context }) => ({ customer: context.customer }),\n component: AccountLayout,\n})\n\nfunction AccountLayout() {\n return (\n
\n \n
\n \n
\n
\n )\n}\n") return __output } function __render_fc9760c0_4770(context: TemplateRenderContext) { const { packageManager, projectName, typescript, tailwind, blank, js, jsx, fileRouter, codeRouter, routerOnly, includeExamples, addOnEnabled, addOnOption, addOns, integrations, routes, getPackageManagerAddScript, getPackageManagerRunScript, getPackageManagerExecuteScript, relativePath, integrationImportContent, integrationImportCode, renderTemplate, ignoreFile, } = context let __output = '' const __append = (value: unknown) => { if (value !== undefined && value !== null) { __output += String(value) } } if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } __append("\n/**\n * GraphQL queries for the Shopify Customer Account API.\n *\n * The Customer Account API has its own schema, distinct from the Storefront\n * API. We hand-type the slices we use; if the surface area grows we can wire\n * up @shopify/customer-account-api-codegen later.\n */\n\nexport const CUSTOMER_QUERY = /* GraphQL */ `\n query Customer {\n customer {\n id\n firstName\n lastName\n emailAddress {\n emailAddress\n }\n phoneNumber {\n phoneNumber\n }\n defaultAddress {\n id\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n phoneNumber\n }\n }\n }\n`\n\nexport type CustomerProfile = {\n id: string\n firstName: string | null\n lastName: string | null\n emailAddress: { emailAddress: string } | null\n phoneNumber: { phoneNumber: string } | null\n defaultAddress: CustomerAddress | null\n}\n\nexport type CustomerQueryResult = { customer: CustomerProfile }\n\nexport type CustomerAddress = {\n id: string\n firstName: string | null\n lastName: string | null\n address1: string | null\n address2: string | null\n city: string | null\n zoneCode: string | null\n zip: string | null\n territoryCode: string | null\n phoneNumber: string | null\n}\n\nexport const CUSTOMER_ADDRESSES_QUERY = /* GraphQL */ `\n query CustomerAddresses($first: Int!) {\n customer {\n addresses(first: $first) {\n nodes {\n id\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n phoneNumber\n }\n }\n defaultAddress {\n id\n }\n }\n }\n`\n\nexport type CustomerAddressesQueryResult = {\n customer: {\n addresses: { nodes: Array }\n defaultAddress: { id: string } | null\n }\n}\n\nexport const ORDERS_QUERY = /* GraphQL */ `\n query CustomerOrders($first: Int!, $after: String) {\n customer {\n orders(first: $first, after: $after, sortKey: PROCESSED_AT, reverse: true) {\n nodes {\n id\n name\n processedAt\n financialStatus\n fulfillmentStatus\n totalPrice {\n amount\n currencyCode\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n`\n\nexport type OrderListItem = {\n id: string\n name: string\n processedAt: string\n financialStatus: string | null\n fulfillmentStatus: string | null\n totalPrice: { amount: string; currencyCode: string }\n}\n\nexport type OrdersQueryResult = {\n customer: {\n orders: {\n nodes: Array\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n }\n }\n}\n\nexport const ORDER_QUERY = /* GraphQL */ `\n query Order($id: ID!) {\n order(id: $id) {\n id\n name\n processedAt\n financialStatus\n fulfillmentStatus\n totalPrice {\n amount\n currencyCode\n }\n subtotal {\n amount\n currencyCode\n }\n totalShipping {\n amount\n currencyCode\n }\n totalTax {\n amount\n currencyCode\n }\n shippingAddress {\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n }\n lineItems(first: 50) {\n nodes {\n id\n title\n quantity\n variantTitle\n price {\n amount\n currencyCode\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n`\n\nexport type OrderDetailLine = {\n id: string\n title: string\n quantity: number\n variantTitle: string | null\n price: { amount: string; currencyCode: string }\n image: {\n url: string\n altText: string | null\n width: number | null\n height: number | null\n } | null\n}\n\nexport type OrderDetail = {\n id: string\n name: string\n processedAt: string\n financialStatus: string | null\n fulfillmentStatus: string | null\n totalPrice: { amount: string; currencyCode: string }\n subtotal: { amount: string; currencyCode: string } | null\n totalShipping: { amount: string; currencyCode: string } | null\n totalTax: { amount: string; currencyCode: string } | null\n shippingAddress: {\n firstName: string | null\n lastName: string | null\n address1: string | null\n address2: string | null\n city: string | null\n zoneCode: string | null\n zip: string | null\n territoryCode: string | null\n } | null\n lineItems: { nodes: Array }\n}\n\nexport type OrderQueryResult = { order: OrderDetail | null }\n") return __output } const templateRenderers: Record = { "49e52a1d:2177": __render_49e52a1d_2177, "4f27c616:2520": __render_4f27c616_2520, "7a173ee3:2276": __render_7a173ee3_2276, "7c920d31:1166": __render_7c920d31_1166, "82c11ef7:9033": __render_82c11ef7_9033, "9bd461a9:1368": __render_9bd461a9_1368, "9ead2ab8:1870": __render_9ead2ab8_1870, "b7239c88:690": __render_b7239c88_690, "c759666f:5091": __render_c759666f_5091, "ca835134:2683": __render_ca835134_2683, "d83ec895:4432": __render_d83ec895_4432, "e9563c39:582": __render_e9563c39_582, "ee45a002:1600": __render_ee45a002_1600, "f8afbed3:961": __render_f8afbed3_961, "fc9760c0:4770": __render_fc9760c0_4770, } export function hasManifestTemplate(template: string) { return getManifestTemplateKey(template) in templateRenderers } export function renderManifestTemplate( template: string, context: TemplateRenderContext, ) { const key = getManifestTemplateKey(template) const renderer = templateRenderers[key] if (!renderer) { throw new Error(`Template ${key} was not precompiled into the manifest`) } return renderer(context) ?? '' } export const addOn = { "name": "Shopify", "description": "Headless Shopify storefront — products, cart, Shopify-hosted checkout, and optional customer accounts. Mounts /shop/* routes alongside your existing app.", "phase": "add-on", "modes": [ "file-router" ], "type": "add-on", "category": "other", "tailwind": true, "color": "#5A31F4", "priority": 80, "link": "https://shopify.dev/docs/storefronts/headless", "dependsOn": [ "tanstack-query" ], "options": { "customerAccount": { "type": "select", "label": "Customer Accounts", "description": "Add /shop/account/* routes (Shopify Customer Account API, OAuth setup required).", "default": "disabled", "options": [ { "value": "disabled", "label": "No (skip account routes)" }, { "value": "enabled", "label": "Yes (include OAuth + account dashboard)" } ] } }, "integrations": [ { "type": "header-user", "jsName": "ShopifyHeaderCart", "path": "src/integrations/shopify/header-cart.tsx" } ], "routes": [ { "icon": "ShoppingBag", "url": "/shop", "name": "Shop", "path": "src/routes/shop.index.tsx", "jsName": "ShopIndex" }, { "url": "/shop/cart", "name": "Cart", "path": "src/routes/shop.cart.tsx", "jsName": "ShopCart" } ], "envVars": [ { "name": "SHOPIFY_STORE_DOMAIN", "description": "Your store's myshopify.com domain. Defaults to Shopify's public Hydrogen demo store.", "required": false, "secret": false, "file": ".env.local" }, { "name": "SHOPIFY_STOREFRONT_API_VERSION", "description": "Storefront API version (e.g. 2026-01).", "required": false, "secret": false, "file": ".env.local" }, { "name": "SHOPIFY_PUBLIC_STOREFRONT_TOKEN", "description": "Public Storefront API token. Defaults to Shopify's public Hydrogen demo token.", "required": false, "secret": false, "file": ".env.local" }, { "name": "SHOPIFY_PRIVATE_STOREFRONT_TOKEN", "description": "Optional private Storefront API token. Higher rate limits + buyer-IP forwarding when set.", "required": false, "secret": true, "file": ".env.local" }, { "name": "SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID", "description": "Customer Account API client ID. Required when customerAccount=enabled.", "required": false, "secret": true, "file": ".env.local" }, { "name": "SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID", "description": "Numeric Shop ID (Shopify admin > Settings > Customer accounts). Required when customerAccount=enabled.", "required": false, "secret": false, "file": ".env.local" }, { "name": "SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI", "description": "OAuth callback URL. Defaults to http://localhost:3000/shop/account/callback.", "required": false, "secret": false, "file": ".env.local" }, { "name": "SHOPIFY_SESSION_SECRET", "description": "32+ char secret used to sign customer session cookies. Required when customerAccount=enabled.", "required": false, "secret": true, "file": ".env.local" } ], "id": "shopify", "version": "0.0.0", "packageAdditions": { "dependencies": { "@shopify/hydrogen-react": "^2026.1.0", "valibot": "^1.0.0" } }, "readme": "# Shopify\n\nHeadless Shopify storefront for TanStack Start. Mounts `/shop/*` routes\nalongside your existing app — your home page stays untouched.\n\nThe default `.env.local` points at Shopify's public Hydrogen demo store, so the\nstorefront renders real products on first run with zero setup.\n\n## Routes\n\n| Route | What it does |\n|--------------------------------|---------------------------------------------|\n| `/shop` | Shop landing — featured products + collections |\n| `/shop/products/$handle` | Product detail (variants, images, options) |\n| `/shop/collections/$handle` | Collection grid with sort + pagination |\n| `/shop/cart` | Cart line items, discount codes, checkout |\n| `/shop/search` | Product search |\n| `/shop/pages/$handle` | Shopify CMS pages (about, etc.) |\n| `/shop/policies/$handle` | Privacy, refund, terms, shipping |\n\nIf you opted into customer accounts during scaffold:\n\n| Route | What it does |\n|--------------------------------------|------------------------------------|\n| `/shop/account/login` | Kick off Shopify OAuth |\n| `/shop/account/callback` | OAuth callback handler |\n| `/shop/account/logout` | End the customer session |\n| `/shop/account` | Dashboard |\n| `/shop/account/orders` | Order history |\n| `/shop/account/orders/$id` | Order detail |\n| `/shop/account/addresses` | Manage saved addresses |\n\n## Connect your store\n\n1. In Shopify admin, go to **Settings > Apps and sales channels > Develop apps**.\n2. Create a new app, enable the **Storefront API**, and copy the public access token.\n3. Set in `.env.local`:\n ```\n SHOPIFY_STORE_DOMAIN=your-store.myshopify.com\n SHOPIFY_PUBLIC_STOREFRONT_TOKEN=...\n ```\n4. (Optional) For higher rate limits + buyer-IP forwarding, also create a private\n token and set `SHOPIFY_PRIVATE_STOREFRONT_TOKEN`.\n\n## Enable customer accounts\n\nIf `customerAccount=enabled` was selected during scaffold:\n\n1. In Shopify admin, go to **Settings > Customer accounts > Headless**.\n2. Register a public client. Add `http://localhost:3000/shop/account/callback`\n *and* your production callback URL to the redirect URIs.\n3. Copy the Client ID and Shop ID into `.env.local`:\n ```\n SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=...\n SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID=...\n SHOPIFY_SESSION_SECRET=$(openssl rand -hex 32)\n ```\n\nThe Hydrogen demo store doesn't have customer accounts configured, so the\ndefault demo creds won't work for `/shop/account/*` — you'll need a real store.\n\n## Architecture\n\n- **Storefront API client** — server-only fetch in `src/server/shopify/storefront-client.ts`.\n All product/cart reads go through the server (private token never reaches the browser).\n- **Cart state** — Cart ID stored in an httpOnly cookie (`tanstack_cart_id`). React\n Query owns the cache (single key `['shopify', 'cart']`); optimistic updates with\n a module-level mutation counter to batch invalidations.\n- **GraphQL queries** — hand-written strings in `src/lib/shopify/queries.ts`, types\n sliced from `@shopify/hydrogen-react/storefront-api-types` (type-only import; zero runtime).\n- **Customer accounts** — hand-rolled OAuth 2.1 PKCE with `.well-known` discovery\n (no usable npm client exists yet). Tokens in a signed httpOnly cookie.\n- **Checkout** — redirects to `cart.checkoutUrl` (Shopify-hosted).\n\n## Deployment\n\nWorks anywhere TanStack Start runs:\n\n- **Node** — `npm run build && npm start`\n- **Cloudflare Workers / Shopify Oxygen** — Oxygen is just Workers under the hood;\n build with the Workers preset and deploy to either platform.\n- **Vercel / Netlify** — set the env vars in the dashboard.\n- **Bun, Deno** — supported via Start's adapters.\n\nFor the customer-account flow, register both your local *and* production\ncallback URLs in the Shopify admin's headless app config.\n", "readmeIsEjs": false, "files": { "_dot_env.local.append": "# ─── Shopify ─────────────────────────────────────────────────────────────────\n# These defaults point to Shopify's public Hydrogen demo store so the storefront\n# works on first run with zero config. Replace with your store's values to go live.\n# Get tokens at: Shopify admin > Settings > Apps and sales channels > Develop apps.\nSHOPIFY_STORE_DOMAIN=hydrogen-preview.myshopify.com\nSHOPIFY_STOREFRONT_API_VERSION=2026-01\nSHOPIFY_PUBLIC_STOREFRONT_TOKEN=3b580e70970c4528da70c98e097c2fa0\n# Optional — set to a private storefront token for higher rate limits + buyer-IP forwarding.\nSHOPIFY_PRIVATE_STOREFRONT_TOKEN=\n\n# ─── Shopify Customer Account API ────────────────────────────────────────────\n# Required only if you opted into customer accounts during scaffold.\n# Register a public client in Shopify admin > Settings > Customer accounts > Headless,\n# then paste the client ID and your numeric shop ID below.\nSHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=\nSHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID=\nSHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI=http://localhost:3000/shop/account/callback\n# 32+ random chars, e.g. `openssl rand -hex 32`. Used to sign customer session cookies.\nSHOPIFY_SESSION_SECRET=\n", "src/components/shop/account-nav.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { Link } from '@tanstack/react-router'\n\nconst ITEMS = [\n { to: '/shop/account', label: 'Overview', exact: true },\n { to: '/shop/account/orders', label: 'Orders', exact: false },\n { to: '/shop/account/addresses', label: 'Addresses', exact: false },\n] as const\n\nexport function AccountNav() {\n return (\n \n )\n}\n", "src/components/shop/add-to-cart-button.tsx": "import { useAddToCart } from '#/hooks/use-cart'\nimport type { ProductDetail, ProductDetailVariant } from '#/lib/shopify/queries'\n\ntype AddToCartButtonProps = {\n product: ProductDetail\n variant: ProductDetailVariant | undefined\n quantity?: number\n}\n\nexport function AddToCartButton({\n product,\n variant,\n quantity = 1,\n}: AddToCartButtonProps) {\n const { mutate, isPending } = useAddToCart()\n const disabled = !variant || !variant.availableForSale || isPending\n\n return (\n {\n if (!variant) return\n mutate({\n variantId: variant.id,\n quantity,\n line: {\n productTitle: product.title,\n productHandle: product.handle,\n variantTitle: variant.title,\n price: variant.price,\n image: variant.image ?? product.images.nodes[0] ?? null,\n selectedOptions: variant.selectedOptions,\n },\n })\n }}\n className=\"w-full rounded-full bg-[var(--storefront-accent)] px-6 py-3.5 text-sm font-medium text-[var(--storefront-accent-fg)] transition disabled:cursor-not-allowed disabled:opacity-50 hover:opacity-90\"\n >\n {!variant\n ? 'Select options'\n : !variant.availableForSale\n ? 'Sold out'\n : isPending\n ? 'Adding…'\n : 'Add to cart'}\n \n )\n}\n", "src/components/shop/cart-line-item.tsx": "import { Link } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport { useRemoveCartLine, useUpdateCartLine } from '#/hooks/use-cart'\nimport type { CartLineDetail } from '#/lib/shopify/queries'\n\ntype CartLineItemProps = {\n line: CartLineDetail\n}\n\nexport function CartLineItem({ line }: CartLineItemProps) {\n const update = useUpdateCartLine()\n const remove = useRemoveCartLine()\n const merch = line.merchandise\n\n return (\n
  • \n \n \n \n
    \n
    \n
    \n \n {merch.product.title}\n \n {merch.title && merch.title !== 'Default Title' && (\n

    \n {merch.selectedOptions.map((o) => o.value).join(' · ')}\n

    \n )}\n
    \n \n
    \n
    \n
    \n \n update.mutate({ lineId: line.id, quantity: line.quantity - 1 })\n }\n className=\"px-3 py-1.5 text-base disabled:opacity-40\"\n >\n −\n \n \n {line.quantity}\n \n \n update.mutate({ lineId: line.id, quantity: line.quantity + 1 })\n }\n className=\"px-3 py-1.5 text-base disabled:opacity-40\"\n >\n +\n \n
    \n remove.mutate({ lineId: line.id })}\n disabled={remove.isPending}\n className=\"text-sm text-[var(--storefront-fg-muted)] underline underline-offset-4 hover:text-[var(--storefront-fg)] disabled:opacity-40\"\n >\n Remove\n \n
    \n
    \n
  • \n )\n}\n", "src/components/shop/cart-summary.tsx": "import { useState } from 'react'\n\nimport { Money } from '#/components/shop/money'\nimport {\n useApplyDiscountCode,\n useRemoveDiscountCode,\n} from '#/hooks/use-cart'\nimport type { CartDetail } from '#/lib/shopify/queries'\n\ntype CartSummaryProps = {\n cart: CartDetail\n}\n\nexport function CartSummary({ cart }: CartSummaryProps) {\n const [code, setCode] = useState('')\n const apply = useApplyDiscountCode()\n const remove = useRemoveDiscountCode()\n const appliedCode = cart.discountCodes.find((c) => c.applicable)\n\n return (\n \n )\n}\n", "src/components/shop/empty-state.tsx": "import { Link } from '@tanstack/react-router'\nimport type { ReactNode } from 'react'\n\ntype EmptyStateProps = {\n title: string\n description?: string\n cta?: { label: string; to: string }\n children?: ReactNode\n}\n\nexport function EmptyState({ title, description, cta, children }: EmptyStateProps) {\n return (\n
    \n

    {title}

    \n {description && (\n

    {description}

    \n )}\n {children}\n {cta && (\n \n {cta.label}\n \n )}\n
    \n )\n}\n", "src/components/shop/money.tsx": "import { formatMoney } from '#/lib/shopify/format'\n\ntype MoneyProps = {\n amount: string | number\n currencyCode: string\n className?: string\n}\n\nexport function Money({ amount, currencyCode, className }: MoneyProps) {\n return {formatMoney(amount, currencyCode)}\n}\n", "src/components/shop/product-card.tsx": "import { Link } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport type { ProductListItem } from '#/lib/shopify/queries'\n\ntype ProductCardProps = {\n product: ProductListItem\n}\n\nexport function ProductCard({ product }: ProductCardProps) {\n const minPrice = product.priceRange.minVariantPrice\n const maxPrice = product.priceRange.maxVariantPrice\n const compareAt = product.compareAtPriceRange.minVariantPrice\n const onSale =\n Number(compareAt.amount) > 0 &&\n Number(compareAt.amount) > Number(minPrice.amount)\n const soldOut = !product.variants.nodes.some((v) => v.availableForSale)\n\n return (\n \n \n \n {soldOut && (\n \n Sold out\n \n )}\n {!soldOut && onSale && (\n \n Sale\n \n )}\n \n
    \n

    {product.title}

    \n

    \n {onSale && (\n \n \n \n )}\n \n {Number(maxPrice.amount) > Number(minPrice.amount) && (\n <>\n {' – '}\n \n \n )}\n

    \n
    \n \n )\n}\n", "src/components/shop/product-grid.tsx": "import { ProductCard } from '#/components/shop/product-card'\nimport type { ProductListItem } from '#/lib/shopify/queries'\n\ntype ProductGridProps = {\n products: ReadonlyArray\n}\n\nexport function ProductGrid({ products }: ProductGridProps) {\n if (products.length === 0) {\n return (\n

    \n No products yet.\n

    \n )\n }\n\n return (\n
    \n {products.map((product) => (\n \n ))}\n
    \n )\n}\n", "src/components/shop/shop-image.tsx": "import { shopifyImageUrl } from '#/lib/shopify/format'\n\ntype ShopImageProps = {\n src: string | null | undefined\n alt: string | null | undefined\n width: number\n height?: number\n className?: string\n sizes?: string\n loading?: 'eager' | 'lazy'\n}\n\nexport function ShopImage({\n src,\n alt,\n width,\n height,\n className,\n sizes,\n loading = 'lazy',\n}: ShopImageProps) {\n if (!src) {\n return (\n \n )\n }\n\n const transformed = shopifyImageUrl(src, { width, height, format: 'webp' })\n const srcSet = [1, 2]\n .map((dpr) => {\n const w = width * dpr\n const h = height ? height * dpr : undefined\n return `${shopifyImageUrl(src, { width: w, height: h, format: 'webp' })} ${dpr}x`\n })\n .join(', ')\n\n return (\n \n )\n}\n", "src/components/shop/shop.css": "/*\n * Shop theme tokens. Override these six variables in your own CSS to re-theme\n * the storefront without touching components.\n */\n:root {\n --storefront-bg: #ffffff;\n --storefront-fg: #0a0a0a;\n --storefront-fg-muted: #525252;\n --storefront-line: #e5e5e5;\n --storefront-accent: #0a0a0a;\n --storefront-accent-fg: #ffffff;\n --storefront-radius: 0.5rem;\n}\n\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme='light']) {\n --storefront-bg: #0a0a0a;\n --storefront-fg: #fafafa;\n --storefront-fg-muted: #a3a3a3;\n --storefront-line: #262626;\n --storefront-accent: #fafafa;\n --storefront-accent-fg: #0a0a0a;\n }\n}\n\n:root[data-theme='dark'] {\n --storefront-bg: #0a0a0a;\n --storefront-fg: #fafafa;\n --storefront-fg-muted: #a3a3a3;\n --storefront-line: #262626;\n --storefront-accent: #fafafa;\n --storefront-accent-fg: #0a0a0a;\n}\n\n.shop-root {\n background: var(--storefront-bg);\n color: var(--storefront-fg);\n}\n\n.shop-prose {\n max-width: 65ch;\n line-height: 1.7;\n}\n.shop-prose h1,\n.shop-prose h2,\n.shop-prose h3 {\n font-weight: 600;\n letter-spacing: -0.02em;\n margin-top: 1.5em;\n}\n.shop-prose p {\n margin: 0.75em 0;\n}\n.shop-prose a {\n color: var(--storefront-accent);\n text-decoration: underline;\n text-underline-offset: 2px;\n}\n", "src/components/shop/variant-selector.tsx": "import type { ProductDetail, ProductDetailVariant } from '#/lib/shopify/queries'\n\ntype VariantSelectorProps = {\n product: ProductDetail\n selectedOptions: Record\n onChange: (next: Record) => void\n}\n\nexport function VariantSelector({\n product,\n selectedOptions,\n onChange,\n}: VariantSelectorProps) {\n if (product.options.length === 1 && product.options[0]?.values.length === 1) {\n return null\n }\n\n return (\n
    \n {product.options.map((option) => (\n
    \n
    \n

    {option.name}

    \n \n {selectedOptions[option.name]}\n \n
    \n
    \n {option.values.map((value) => {\n const selected = selectedOptions[option.name] === value\n const wouldBe = { ...selectedOptions, [option.name]: value }\n const matchingVariant = findVariant(product.variants.nodes, wouldBe)\n const available = matchingVariant?.availableForSale ?? false\n return (\n onChange(wouldBe)}\n disabled={!available && !selected}\n className={[\n 'min-w-[3rem] rounded-full border px-4 py-2 text-sm transition',\n selected\n ? 'border-[var(--storefront-accent)] bg-[var(--storefront-accent)] text-[var(--storefront-accent-fg)]'\n : 'border-[var(--storefront-line)] hover:border-[var(--storefront-accent)]',\n !available && !selected\n ? 'cursor-not-allowed opacity-40 line-through'\n : '',\n ].join(' ')}\n >\n {value}\n \n )\n })}\n
    \n
    \n ))}\n
    \n )\n}\n\nexport function findVariant(\n variants: ReadonlyArray,\n selected: Record,\n): ProductDetailVariant | undefined {\n return variants.find((variant) =>\n variant.selectedOptions.every(\n (opt) => selected[opt.name] === opt.value,\n ),\n )\n}\n\nexport function defaultSelectedOptions(\n product: ProductDetail,\n): Record {\n const firstAvailable = product.variants.nodes.find((v) => v.availableForSale)\n const source = firstAvailable ?? product.variants.nodes[0]\n if (!source) return {}\n return Object.fromEntries(source.selectedOptions.map((o) => [o.name, o.value]))\n}\n", "src/hooks/use-cart.ts": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport type {\n Image as StorefrontImage,\n MoneyV2,\n} from '@shopify/hydrogen-react/storefront-api-types'\n\nimport {\n addToCart,\n applyDiscountCode,\n getCart,\n removeCartLine,\n removeDiscountCode,\n updateCartLine,\n} from '#/server/shopify/cart.functions'\nimport type { CartDetail, CartLineDetail } from '#/lib/shopify/queries'\n\n/**\n * Shared React Query key for the current user's cart.\n *\n * The cart ID lives in an httpOnly cookie on the server, so the client never\n * needs to know it — a single cache key is enough. Route loaders can prefetch\n * into this key so the first render already has the data.\n */\nexport const CART_QUERY_KEY = ['shopify', 'cart'] as const\n\nconst CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const\n\n/**\n * Explicit in-flight counter. We don't rely on `queryClient.isMutating()`\n * because its semantics at `onSettled` time vary across React Query versions.\n * A module-level counter is unambiguous: increment in onMutate, decrement in\n * onSettled, invalidate when the count hits zero.\n *\n * @see https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query\n */\nlet cartMutationsInFlight = 0\n\nfunction trackMutationStart() {\n cartMutationsInFlight++\n}\n\nfunction settleWhenIdle(qc: ReturnType) {\n cartMutationsInFlight = Math.max(0, cartMutationsInFlight - 1)\n if (cartMutationsInFlight === 0) {\n return qc.invalidateQueries({ queryKey: CART_QUERY_KEY })\n }\n}\n\nexport function useCart() {\n const query = useQuery({\n queryKey: CART_QUERY_KEY,\n queryFn: () => getCart(),\n staleTime: 30_000,\n })\n\n return {\n cart: query.data ?? null,\n isLoading: query.isLoading,\n isFetching: query.isFetching,\n isError: query.isError,\n error: query.error,\n refetch: query.refetch,\n totalQuantity: query.data?.totalQuantity ?? 0,\n }\n}\n\ntype AddToCartLineSnapshot = {\n productTitle: string\n productHandle: string\n variantTitle: string\n price: Pick\n image: Pick | null\n selectedOptions: Array<{ name: string; value: string }>\n}\n\ntype AddToCartInput = {\n variantId: string\n quantity?: number\n /** Product snapshot for optimistic line rendering. */\n line?: AddToCartLineSnapshot\n}\n\nexport function useAddToCart() {\n const qc = useQueryClient()\n\n return useMutation({\n mutationKey: CART_MUTATION_KEY,\n mutationFn: (input: AddToCartInput) =>\n addToCart({\n data: { variantId: input.variantId, quantity: input.quantity ?? 1 },\n }),\n\n onMutate: async (input) => {\n trackMutationStart()\n const quantity = input.quantity ?? 1\n await qc.cancelQueries({ queryKey: CART_QUERY_KEY })\n const previous = qc.getQueryData(CART_QUERY_KEY)\n\n if (previous && input.line) {\n const snap = input.line\n const existingIdx = previous.lines.nodes.findIndex(\n (l) => l.merchandise.id === input.variantId,\n )\n\n let nextLines: CartDetail['lines']['nodes']\n if (existingIdx >= 0) {\n nextLines = previous.lines.nodes.map((l, i) =>\n i === existingIdx ? { ...l, quantity: l.quantity + quantity } : l,\n )\n } else {\n const lineTotal = String(Number(snap.price.amount) * quantity)\n nextLines = [\n {\n id: `optimistic-${Date.now()}`,\n quantity,\n merchandise: {\n id: input.variantId,\n title: snap.variantTitle,\n availableForSale: true,\n selectedOptions: snap.selectedOptions,\n price: snap.price,\n image: snap.image,\n product: {\n handle: snap.productHandle,\n title: snap.productTitle,\n },\n },\n cost: {\n totalAmount: {\n amount: lineTotal,\n currencyCode: snap.price.currencyCode,\n },\n },\n } satisfies CartLineDetail,\n ...previous.lines.nodes,\n ]\n }\n\n qc.setQueryData(CART_QUERY_KEY, {\n ...previous,\n totalQuantity: nextLines.reduce((s, l) => s + l.quantity, 0),\n lines: { ...previous.lines, nodes: nextLines },\n })\n } else if (previous) {\n qc.setQueryData(CART_QUERY_KEY, {\n ...previous,\n totalQuantity: (previous.totalQuantity ?? 0) + quantity,\n })\n }\n\n return { previous }\n },\n\n onError: (_err, _input, ctx) => {\n if (ctx?.previous !== undefined)\n qc.setQueryData(CART_QUERY_KEY, ctx.previous)\n },\n\n onSuccess: (cart) => {\n qc.setQueryData(CART_QUERY_KEY, cart)\n },\n\n onSettled: () => settleWhenIdle(qc),\n })\n}\n\nexport function useUpdateCartLine() {\n const qc = useQueryClient()\n return useMutation({\n mutationKey: CART_MUTATION_KEY,\n mutationFn: (input: { lineId: string; quantity: number }) =>\n updateCartLine({ data: input }),\n\n onMutate: async (input) => {\n trackMutationStart()\n await qc.cancelQueries({ queryKey: CART_QUERY_KEY })\n const previous = qc.getQueryData(CART_QUERY_KEY)\n if (previous) {\n const nextLines = previous.lines.nodes.map((line) =>\n line.id === input.lineId\n ? { ...line, quantity: input.quantity }\n : line,\n )\n const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0)\n qc.setQueryData(CART_QUERY_KEY, {\n ...previous,\n totalQuantity: nextQty,\n lines: { ...previous.lines, nodes: nextLines },\n })\n }\n return { previous }\n },\n\n onError: (_err, _input, ctx) => {\n if (ctx?.previous !== undefined)\n qc.setQueryData(CART_QUERY_KEY, ctx.previous)\n },\n\n onSettled: () => settleWhenIdle(qc),\n })\n}\n\nexport function useRemoveCartLine() {\n const qc = useQueryClient()\n return useMutation({\n mutationKey: CART_MUTATION_KEY,\n mutationFn: (input: { lineId: string }) => removeCartLine({ data: input }),\n\n onMutate: async (input) => {\n trackMutationStart()\n await qc.cancelQueries({ queryKey: CART_QUERY_KEY })\n const previous = qc.getQueryData(CART_QUERY_KEY)\n if (previous) {\n const nextLines = previous.lines.nodes.filter(\n (line) => line.id !== input.lineId,\n )\n const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0)\n qc.setQueryData(CART_QUERY_KEY, {\n ...previous,\n totalQuantity: nextQty,\n lines: { ...previous.lines, nodes: nextLines },\n })\n }\n return { previous }\n },\n\n onError: (_err, _input, ctx) => {\n if (ctx?.previous !== undefined)\n qc.setQueryData(CART_QUERY_KEY, ctx.previous)\n },\n\n onSettled: () => settleWhenIdle(qc),\n })\n}\n\nexport function useApplyDiscountCode() {\n const qc = useQueryClient()\n return useMutation({\n mutationKey: CART_MUTATION_KEY,\n mutationFn: (input: { code: string }) =>\n applyDiscountCode({ data: { code: input.code } }),\n onMutate: async () => {\n trackMutationStart()\n await qc.cancelQueries({ queryKey: CART_QUERY_KEY })\n },\n onSuccess: (cart) => {\n qc.setQueryData(CART_QUERY_KEY, cart)\n },\n onSettled: () => settleWhenIdle(qc),\n })\n}\n\nexport function useRemoveDiscountCode() {\n const qc = useQueryClient()\n return useMutation({\n mutationKey: CART_MUTATION_KEY,\n mutationFn: () => removeDiscountCode(),\n onMutate: async () => {\n trackMutationStart()\n await qc.cancelQueries({ queryKey: CART_QUERY_KEY })\n const previous = qc.getQueryData(CART_QUERY_KEY)\n if (previous) {\n qc.setQueryData(CART_QUERY_KEY, {\n ...previous,\n discountCodes: [],\n })\n }\n return { previous }\n },\n onError: (_err, _input, ctx) => {\n if (ctx?.previous !== undefined)\n qc.setQueryData(CART_QUERY_KEY, ctx.previous)\n },\n onSettled: () => settleWhenIdle(qc),\n })\n}\n", "src/hooks/use-customer.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { useQuery } from '@tanstack/react-query'\n\nimport { getCustomer } from '#/server/shopify/customer.functions'\nimport type { CustomerProfile } from '#/lib/shopify/customer-queries'\n\nexport const CUSTOMER_QUERY_KEY = ['shopify', 'customer'] as const\n\nexport function useCustomer() {\n const query = useQuery({\n queryKey: CUSTOMER_QUERY_KEY,\n queryFn: () => getCustomer(),\n staleTime: 60_000,\n })\n return {\n customer: query.data ?? null,\n isLoading: query.isLoading,\n isFetching: query.isFetching,\n isError: query.isError,\n error: query.error,\n }\n}\n", "src/integrations/shopify/header-cart.tsx": "import { Link } from '@tanstack/react-router'\n\nimport { useCart } from '#/hooks/use-cart'\n\nexport default function ShopifyHeaderCart() {\n const { totalQuantity } = useCart()\n return (\n \n \n \n \n \n \n {totalQuantity > 0 && (\n \n {totalQuantity}\n \n )}\n \n )\n}\n", "src/lib/shopify/customer-queries.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\n/**\n * GraphQL queries for the Shopify Customer Account API.\n *\n * The Customer Account API has its own schema, distinct from the Storefront\n * API. We hand-type the slices we use; if the surface area grows we can wire\n * up @shopify/customer-account-api-codegen later.\n */\n\nexport const CUSTOMER_QUERY = /* GraphQL */ `\n query Customer {\n customer {\n id\n firstName\n lastName\n emailAddress {\n emailAddress\n }\n phoneNumber {\n phoneNumber\n }\n defaultAddress {\n id\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n phoneNumber\n }\n }\n }\n`\n\nexport type CustomerProfile = {\n id: string\n firstName: string | null\n lastName: string | null\n emailAddress: { emailAddress: string } | null\n phoneNumber: { phoneNumber: string } | null\n defaultAddress: CustomerAddress | null\n}\n\nexport type CustomerQueryResult = { customer: CustomerProfile }\n\nexport type CustomerAddress = {\n id: string\n firstName: string | null\n lastName: string | null\n address1: string | null\n address2: string | null\n city: string | null\n zoneCode: string | null\n zip: string | null\n territoryCode: string | null\n phoneNumber: string | null\n}\n\nexport const CUSTOMER_ADDRESSES_QUERY = /* GraphQL */ `\n query CustomerAddresses($first: Int!) {\n customer {\n addresses(first: $first) {\n nodes {\n id\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n phoneNumber\n }\n }\n defaultAddress {\n id\n }\n }\n }\n`\n\nexport type CustomerAddressesQueryResult = {\n customer: {\n addresses: { nodes: Array }\n defaultAddress: { id: string } | null\n }\n}\n\nexport const ORDERS_QUERY = /* GraphQL */ `\n query CustomerOrders($first: Int!, $after: String) {\n customer {\n orders(first: $first, after: $after, sortKey: PROCESSED_AT, reverse: true) {\n nodes {\n id\n name\n processedAt\n financialStatus\n fulfillmentStatus\n totalPrice {\n amount\n currencyCode\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n`\n\nexport type OrderListItem = {\n id: string\n name: string\n processedAt: string\n financialStatus: string | null\n fulfillmentStatus: string | null\n totalPrice: { amount: string; currencyCode: string }\n}\n\nexport type OrdersQueryResult = {\n customer: {\n orders: {\n nodes: Array\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n }\n }\n}\n\nexport const ORDER_QUERY = /* GraphQL */ `\n query Order($id: ID!) {\n order(id: $id) {\n id\n name\n processedAt\n financialStatus\n fulfillmentStatus\n totalPrice {\n amount\n currencyCode\n }\n subtotal {\n amount\n currencyCode\n }\n totalShipping {\n amount\n currencyCode\n }\n totalTax {\n amount\n currencyCode\n }\n shippingAddress {\n firstName\n lastName\n address1\n address2\n city\n zoneCode\n zip\n territoryCode\n }\n lineItems(first: 50) {\n nodes {\n id\n title\n quantity\n variantTitle\n price {\n amount\n currencyCode\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n`\n\nexport type OrderDetailLine = {\n id: string\n title: string\n quantity: number\n variantTitle: string | null\n price: { amount: string; currencyCode: string }\n image: {\n url: string\n altText: string | null\n width: number | null\n height: number | null\n } | null\n}\n\nexport type OrderDetail = {\n id: string\n name: string\n processedAt: string\n financialStatus: string | null\n fulfillmentStatus: string | null\n totalPrice: { amount: string; currencyCode: string }\n subtotal: { amount: string; currencyCode: string } | null\n totalShipping: { amount: string; currencyCode: string } | null\n totalTax: { amount: string; currencyCode: string } | null\n shippingAddress: {\n firstName: string | null\n lastName: string | null\n address1: string | null\n address2: string | null\n city: string | null\n zoneCode: string | null\n zip: string | null\n territoryCode: string | null\n } | null\n lineItems: { nodes: Array }\n}\n\nexport type OrderQueryResult = { order: OrderDetail | null }\n", "src/lib/shopify/format.ts": "/**\n * Browser-safe formatting helpers for Shopify Storefront API responses.\n * Framework-free — no React, no provider context required.\n */\n\nexport function formatMoney(amount: string | number, currencyCode: string) {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: currencyCode,\n minimumFractionDigits: 0,\n maximumFractionDigits: 0,\n }).format(typeof amount === 'string' ? Number(amount) : amount)\n}\n\ntype ShopifyImageOptions = {\n width?: number\n height?: number\n format?: 'webp' | 'jpg' | 'png'\n crop?: 'center' | 'top' | 'bottom' | 'left' | 'right'\n}\n\n/**\n * Append Shopify CDN transform parameters to a product image URL.\n * Shopify's CDN serves resized/reformatted versions automatically.\n */\nexport function shopifyImageUrl(url: string, opts: ShopifyImageOptions = {}) {\n const u = new URL(url)\n if (opts.width) u.searchParams.set('width', String(opts.width))\n if (opts.height) u.searchParams.set('height', String(opts.height))\n if (opts.format) u.searchParams.set('format', opts.format)\n if (opts.crop) u.searchParams.set('crop', opts.crop)\n return u.toString()\n}\n", "src/lib/shopify/queries.ts": "import type {\n Cart,\n CartLine,\n Collection,\n Image as StorefrontImage,\n MoneyV2,\n Page,\n Product,\n ProductOption,\n ProductSortKeys,\n ProductVariant,\n} from '@shopify/hydrogen-react/storefront-api-types'\n\n/**\n * GraphQL queries for the Shopify Storefront API.\n *\n * Result types are hand-picked slices of `@shopify/hydrogen-react`\n * Storefront API types. The `@shopify/hydrogen-react` import is type-only\n * (zero runtime cost). When the query count grows, swap to\n * `@shopify/hydrogen-codegen` and regenerate; consuming code won't change.\n */\n\n/* ─── Shop info ─────────────────────────────────────────────────────────── */\n\nexport const SHOP_QUERY = /* GraphQL */ `\n query Shop {\n shop {\n name\n description\n primaryDomain {\n url\n }\n }\n }\n`\n\nexport type ShopQueryResult = {\n shop: {\n name: string\n description: string | null\n primaryDomain: { url: string }\n }\n}\n\n/* ─── Product card fragment + product list ──────────────────────────────── */\n\nconst PRODUCT_CARD_FRAGMENT = /* GraphQL */ `\n fragment ProductCard on Product {\n id\n handle\n title\n productType\n tags\n publishedAt\n options {\n name\n values\n }\n featuredImage {\n url\n altText\n width\n height\n }\n variants(first: 1) {\n nodes {\n id\n availableForSale\n }\n }\n priceRange {\n minVariantPrice {\n amount\n currencyCode\n }\n maxVariantPrice {\n amount\n currencyCode\n }\n }\n compareAtPriceRange {\n minVariantPrice {\n amount\n currencyCode\n }\n }\n }\n`\n\nexport const PRODUCTS_QUERY = /* GraphQL */ `\n ${PRODUCT_CARD_FRAGMENT}\n query Products(\n $first: Int!\n $after: String\n $sortKey: ProductSortKeys\n $reverse: Boolean\n ) {\n products(\n first: $first\n after: $after\n sortKey: $sortKey\n reverse: $reverse\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n ...ProductCard\n }\n }\n }\n`\n\ntype CardImage = Pick<\n StorefrontImage,\n 'url' | 'altText' | 'width' | 'height'\n> | null\n\nexport type ProductListItem = Pick<\n Product,\n 'id' | 'handle' | 'title' | 'productType' | 'tags' | 'publishedAt'\n> & {\n options: Array>\n featuredImage: CardImage\n variants: {\n nodes: Array<{ id: string; availableForSale: boolean }>\n }\n priceRange: {\n minVariantPrice: Pick\n maxVariantPrice: Pick\n }\n compareAtPriceRange: {\n minVariantPrice: Pick\n }\n}\n\nexport type ProductListPage = {\n nodes: Array\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n}\n\nexport type ProductsQueryVariables = {\n first: number\n after?: string | null\n sortKey?: ProductSortKeys | null\n reverse?: boolean | null\n}\n\nexport type ProductsQueryResult = {\n products: ProductListPage\n}\n\n/* ─── Single product (PDP) ──────────────────────────────────────────────── */\n\nexport const PRODUCT_QUERY = /* GraphQL */ `\n query Product($handle: String!) {\n product(handle: $handle) {\n id\n handle\n title\n descriptionHtml\n options {\n id\n name\n values\n }\n images(first: 10) {\n nodes {\n url\n altText\n width\n height\n }\n }\n variants(first: 100) {\n nodes {\n id\n title\n availableForSale\n selectedOptions {\n name\n value\n }\n price {\n amount\n currencyCode\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n seo {\n title\n description\n }\n }\n }\n`\n\nexport type ProductDetailVariant = Pick<\n ProductVariant,\n 'id' | 'title' | 'availableForSale'\n> & {\n selectedOptions: Array<{ name: string; value: string }>\n price: Pick\n image: Pick | null\n}\n\nexport type ProductDetail = Pick<\n Product,\n 'id' | 'handle' | 'title' | 'descriptionHtml'\n> & {\n options: Array>\n images: {\n nodes: Array>\n }\n variants: { nodes: Array }\n seo: { title: string | null; description: string | null }\n}\n\nexport type ProductQueryResult = {\n product: ProductDetail | null\n}\n\n/* ─── Collections list ──────────────────────────────────────────────────── */\n\nexport const COLLECTIONS_QUERY = /* GraphQL */ `\n query Collections($first: Int!) {\n collections(first: $first, sortKey: TITLE) {\n nodes {\n id\n handle\n title\n description\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n`\n\nexport type CollectionListItem = Pick<\n Collection,\n 'id' | 'handle' | 'title' | 'description'\n> & {\n image: Pick | null\n}\n\nexport type CollectionsQueryResult = {\n collections: { nodes: Array }\n}\n\n/* ─── Collection by handle ──────────────────────────────────────────────── */\n\nexport const COLLECTION_QUERY = /* GraphQL */ `\n ${PRODUCT_CARD_FRAGMENT}\n query Collection(\n $handle: String!\n $first: Int!\n $after: String\n $sortKey: ProductCollectionSortKeys\n $reverse: Boolean\n ) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n descriptionHtml\n image {\n url\n altText\n width\n height\n }\n seo {\n title\n description\n }\n products(\n first: $first\n after: $after\n sortKey: $sortKey\n reverse: $reverse\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n ...ProductCard\n }\n }\n }\n }\n`\n\nexport type CollectionDetail = Pick<\n Collection,\n 'id' | 'handle' | 'title' | 'description' | 'descriptionHtml'\n> & {\n image: Pick | null\n seo: { title: string | null; description: string | null }\n products: ProductListPage\n}\n\nexport type CollectionQueryResult = {\n collection: CollectionDetail | null\n}\n\n/* ─── Cart fragment + queries + mutations ───────────────────────────────── */\n\nconst CART_FRAGMENT = /* GraphQL */ `\n fragment CartFields on Cart {\n id\n checkoutUrl\n totalQuantity\n cost {\n totalAmount {\n amount\n currencyCode\n }\n subtotalAmount {\n amount\n currencyCode\n }\n totalTaxAmount {\n amount\n currencyCode\n }\n }\n lines(first: 100) {\n nodes {\n id\n quantity\n merchandise {\n ... on ProductVariant {\n id\n title\n availableForSale\n selectedOptions {\n name\n value\n }\n price {\n amount\n currencyCode\n }\n image {\n url\n altText\n width\n height\n }\n product {\n handle\n title\n }\n }\n }\n cost {\n totalAmount {\n amount\n currencyCode\n }\n }\n }\n }\n discountCodes {\n code\n applicable\n }\n }\n`\n\nexport const CART_QUERY = /* GraphQL */ `\n ${CART_FRAGMENT}\n query Cart($cartId: ID!) {\n cart(id: $cartId) {\n ...CartFields\n }\n }\n`\n\nexport const CART_CREATE_MUTATION = /* GraphQL */ `\n ${CART_FRAGMENT}\n mutation CartCreate($input: CartInput!) {\n cartCreate(input: $input) {\n cart {\n ...CartFields\n }\n userErrors {\n field\n message\n }\n }\n }\n`\n\nexport const CART_LINES_ADD_MUTATION = /* GraphQL */ `\n ${CART_FRAGMENT}\n mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {\n cartLinesAdd(cartId: $cartId, lines: $lines) {\n cart {\n ...CartFields\n }\n userErrors {\n field\n message\n }\n }\n }\n`\n\nexport const CART_LINES_UPDATE_MUTATION = /* GraphQL */ `\n ${CART_FRAGMENT}\n mutation CartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {\n cartLinesUpdate(cartId: $cartId, lines: $lines) {\n cart {\n ...CartFields\n }\n userErrors {\n field\n message\n }\n }\n }\n`\n\nexport const CART_LINES_REMOVE_MUTATION = /* GraphQL */ `\n ${CART_FRAGMENT}\n mutation CartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {\n cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {\n cart {\n ...CartFields\n }\n userErrors {\n field\n message\n }\n }\n }\n`\n\nexport const CART_DISCOUNT_CODES_UPDATE_MUTATION = /* GraphQL */ `\n ${CART_FRAGMENT}\n mutation CartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]) {\n cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {\n cart {\n ...CartFields\n }\n userErrors {\n field\n message\n }\n }\n }\n`\n\nexport type CartLineMerchandise = Pick<\n ProductVariant,\n 'id' | 'title' | 'availableForSale'\n> & {\n selectedOptions: Array<{ name: string; value: string }>\n price: Pick\n image: Pick | null\n product: Pick\n}\n\nexport type CartLineDetail = Pick & {\n merchandise: CartLineMerchandise\n cost: {\n totalAmount: Pick\n }\n}\n\nexport type CartDetail = Pick & {\n cost: {\n totalAmount: Pick\n subtotalAmount: Pick\n totalTaxAmount: Pick | null\n }\n lines: { nodes: Array }\n discountCodes: Array<{ code: string; applicable: boolean }>\n}\n\nexport type CartQueryResult = { cart: CartDetail | null }\nexport type CartUserError = { field: string[] | null; message: string }\n\ntype CartMutationResult = {\n [K in TName]: { cart: CartDetail | null; userErrors: Array }\n}\n\nexport type CartCreateResult = CartMutationResult<'cartCreate'>\nexport type CartLinesAddResult = CartMutationResult<'cartLinesAdd'>\nexport type CartLinesUpdateResult = CartMutationResult<'cartLinesUpdate'>\nexport type CartLinesRemoveResult = CartMutationResult<'cartLinesRemove'>\nexport type CartDiscountCodesUpdateResult =\n CartMutationResult<'cartDiscountCodesUpdate'>\n\n/* ─── Sort options ──────────────────────────────────────────────────────── */\n\nexport const SORT_OPTIONS = [\n { key: 'BEST_SELLING', reverse: false, label: 'Best selling' },\n { key: 'CREATED_AT', reverse: true, label: 'Newest' },\n { key: 'PRICE', reverse: false, label: 'Price: low to high' },\n { key: 'PRICE', reverse: true, label: 'Price: high to low' },\n { key: 'TITLE', reverse: false, label: 'Title: A–Z' },\n] as const satisfies ReadonlyArray<{\n key: ProductSortKeys\n reverse: boolean\n label: string\n}>\n\nexport type SortOption = (typeof SORT_OPTIONS)[number]\nexport type SortOptionId = `${SortOption['key']}${'' | ':rev'}`\n\nexport function sortOptionId(opt: SortOption): SortOptionId {\n return (opt.reverse ? `${opt.key}:rev` : opt.key) as SortOptionId\n}\n\nexport function resolveSortOption(id: string | undefined): SortOption {\n if (!id) return SORT_OPTIONS[0]\n for (const opt of SORT_OPTIONS) {\n if (sortOptionId(opt) === id) return opt\n }\n return SORT_OPTIONS[0]\n}\n\nexport const COLLECTION_SORT_OPTIONS = [\n { key: 'COLLECTION_DEFAULT', reverse: false, label: 'Featured' },\n { key: 'BEST_SELLING', reverse: false, label: 'Best selling' },\n { key: 'CREATED', reverse: true, label: 'Newest' },\n { key: 'PRICE', reverse: false, label: 'Price: low to high' },\n { key: 'PRICE', reverse: true, label: 'Price: high to low' },\n { key: 'TITLE', reverse: false, label: 'Title: A–Z' },\n] as const satisfies ReadonlyArray<{\n key: string\n reverse: boolean\n label: string\n}>\n\nexport type CollectionSortOption = (typeof COLLECTION_SORT_OPTIONS)[number]\n\nexport function resolveCollectionSortOption(\n id: string | undefined,\n): CollectionSortOption {\n if (!id) return COLLECTION_SORT_OPTIONS[0]\n const expected = (opt: CollectionSortOption) =>\n opt.reverse ? `${opt.key}:rev` : opt.key\n for (const opt of COLLECTION_SORT_OPTIONS) {\n if (expected(opt) === id) return opt\n }\n return COLLECTION_SORT_OPTIONS[0]\n}\n\n/* ─── Pages + policies ──────────────────────────────────────────────────── */\n\nexport const PAGE_QUERY = /* GraphQL */ `\n query Page($handle: String!) {\n page(handle: $handle) {\n id\n handle\n title\n body\n bodySummary\n seo {\n title\n description\n }\n }\n }\n`\n\nexport type PageDetail = Pick<\n Page,\n 'id' | 'handle' | 'title' | 'body' | 'bodySummary'\n> & {\n seo: { title: string | null; description: string | null }\n}\n\nexport type PageQueryResult = { page: PageDetail | null }\n\nexport const SHOP_POLICIES_QUERY = /* GraphQL */ `\n query ShopPolicies {\n shop {\n privacyPolicy {\n handle\n title\n body\n }\n refundPolicy {\n handle\n title\n body\n }\n termsOfService {\n handle\n title\n body\n }\n shippingPolicy {\n handle\n title\n body\n }\n }\n }\n`\n\nexport type ShopPolicy = {\n handle: string\n title: string\n body: string\n} | null\n\nexport type ShopPoliciesQueryResult = {\n shop: {\n privacyPolicy: ShopPolicy\n refundPolicy: ShopPolicy\n termsOfService: ShopPolicy\n shippingPolicy: ShopPolicy\n }\n}\n\nexport type PolicySummary = { handle: string; title: string }\n\nexport function flattenPolicies(\n shop: ShopPoliciesQueryResult['shop'],\n): Array {\n const keys = [\n 'shippingPolicy',\n 'refundPolicy',\n 'privacyPolicy',\n 'termsOfService',\n ] as const\n return keys.flatMap((k) => {\n const p = shop[k]\n return p ? [{ handle: p.handle, title: p.title }] : []\n })\n}\n\n/* ─── Search ────────────────────────────────────────────────────────────── */\n\nexport const SEARCH_QUERY = /* GraphQL */ `\n ${PRODUCT_CARD_FRAGMENT}\n query Search($query: String!, $first: Int!, $after: String) {\n search(\n query: $query\n first: $first\n after: $after\n types: [PRODUCT]\n productFilters: [{ available: true }]\n ) {\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n ... on Product {\n ...ProductCard\n }\n }\n }\n }\n`\n\nexport type SearchQueryResult = {\n search: {\n totalCount: number\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n nodes: Array\n }\n}\n", "src/routes/shop.account.addresses.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { createFileRoute } from '@tanstack/react-router'\n\nimport { getAddresses } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/addresses')({\n loader: () => getAddresses(),\n component: AddressesRoute,\n})\n\nfunction AddressesRoute() {\n const { addresses, defaultAddressId } = Route.useLoaderData()\n\n if (addresses.length === 0) {\n return (\n
    \n

    Addresses

    \n
    \n No saved addresses yet. Addresses you use at checkout will appear here.\n
    \n
    \n )\n }\n\n return (\n
    \n

    Addresses

    \n
      \n {addresses.map((address) => (\n \n

      \n {[address.firstName, address.lastName].filter(Boolean).join(' ')}\n {address.id === defaultAddressId && (\n \n Default\n \n )}\n

      \n

      \n {address.address1}\n {address.address2 && (\n <>\n
      \n {address.address2}\n \n )}\n
      \n {[address.city, address.zoneCode, address.zip]\n .filter(Boolean)\n .join(', ')}\n
      \n {address.territoryCode}\n

      \n {address.phoneNumber && (\n

      \n {address.phoneNumber}\n

      \n )}\n \n ))}\n
    \n
    \n )\n}\n", "src/routes/shop.account.callback.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport * as v from 'valibot'\n\nimport { completeLogin } from '#/server/shopify/customer.functions'\n\nconst CallbackSearch = v.object({\n code: v.optional(v.string()),\n state: v.optional(v.string()),\n error: v.optional(v.string()),\n error_description: v.optional(v.string()),\n})\n\nexport const Route = createFileRoute('/shop/account/callback')({\n validateSearch: (search) => v.parse(CallbackSearch, search),\n beforeLoad: async ({ search }) => {\n if (search.error || !search.code || !search.state) {\n return { error: search.error_description ?? search.error ?? 'Missing OAuth code/state.' }\n }\n try {\n const { redirectAfter } = await completeLogin({\n data: { code: search.code, state: search.state },\n })\n throw redirect({ to: redirectAfter || '/shop/account' })\n } catch (err) {\n if (err && typeof err === 'object' && 'isRedirect' in err) throw err\n return { error: err instanceof Error ? err.message : String(err) }\n }\n },\n loader: ({ context }) => ({ error: context.error }),\n component: CallbackRoute,\n})\n\nfunction CallbackRoute() {\n const { error } = Route.useLoaderData()\n if (error) {\n return (\n
    \n

    Sign-in failed

    \n

    {error}

    \n
    \n )\n }\n return null\n}\n", "src/routes/shop.account.index.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { Link, createFileRoute } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { getOrders } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/')({\n loader: () => getOrders({ data: { first: 5 } }),\n component: AccountOverview,\n})\n\nfunction AccountOverview() {\n const orders = Route.useLoaderData()\n const { customer } = Route.useRouteContext()\n\n return (\n
    \n
    \n

    \n Welcome back\n

    \n

    \n {customer.firstName ?? customer.emailAddress?.emailAddress ?? 'Customer'}\n

    \n
    \n\n
    \n
    \n

    Recent orders

    \n \n View all\n \n
    \n {orders.nodes.length === 0 ? (\n

    No orders yet.

    \n ) : (\n
      \n {orders.nodes.map((order) => (\n \n
      \n \n {order.name}\n \n

      \n {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'}\n

      \n
      \n \n \n ))}\n
    \n )}\n
    \n
    \n )\n}\n", "src/routes/shop.account.login.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { useEffect, useState } from 'react'\nimport { createFileRoute } from '@tanstack/react-router'\nimport * as v from 'valibot'\n\nimport { isCustomerAccountConfigured } from '#/server/shopify/env'\nimport { startLogin } from '#/server/shopify/customer.functions'\n\nconst LoginSearch = v.object({ redirect: v.optional(v.string(), '/shop/account') })\n\nexport const Route = createFileRoute('/shop/account/login')({\n validateSearch: (search) => v.parse(LoginSearch, search),\n loader: () => ({ configured: isCustomerAccountConfigured() }),\n component: LoginRoute,\n})\n\nfunction LoginRoute() {\n const { redirect } = Route.useSearch()\n const { configured } = Route.useLoaderData()\n const [error, setError] = useState(null)\n\n useEffect(() => {\n if (!configured) return\n startLogin({ data: { redirectAfter: redirect } })\n .then(({ authorizationUrl }) => {\n window.location.assign(authorizationUrl)\n })\n .catch((err) => setError(err instanceof Error ? err.message : String(err)))\n }, [configured, redirect])\n\n if (!configured) {\n return (\n
    \n

    Customer accounts not configured

    \n

    \n Set SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,{' '}\n SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID, and{' '}\n SHOPIFY_SESSION_SECRET in .env.local, then\n register the redirect URI in your Shopify admin.\n

    \n
    \n )\n }\n\n if (error) {\n return (\n
    \n

    Couldn't start sign-in

    \n

    {error}

    \n
    \n )\n }\n\n return (\n

    \n Redirecting to Shopify…\n

    \n )\n}\n", "src/routes/shop.account.logout.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { createFileRoute, redirect } from '@tanstack/react-router'\n\nimport { logout } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/logout')({\n beforeLoad: async () => {\n const { endSessionUrl } = await logout()\n if (endSessionUrl) {\n // Bounce through Shopify's end-session endpoint to clear their session too.\n throw redirect({ href: endSessionUrl })\n }\n throw redirect({ to: '/shop' })\n },\n component: () => null,\n})\n", "src/routes/shop.account.orders.$id.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport { getOrder } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/orders/$id')({\n loader: async ({ params }) => {\n const order = await getOrder({ data: { id: decodeURIComponent(params.id) } })\n if (!order) throw notFound()\n return { order }\n },\n component: OrderDetailRoute,\n})\n\nfunction OrderDetailRoute() {\n const { order } = Route.useLoaderData()\n\n return (\n
    \n
    \n

    {order.name}

    \n

    \n Placed {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'}\n

    \n
    \n\n
      \n {order.lineItems.nodes.map((line) => (\n
    • \n \n
      \n

      {line.title}

      \n {line.variantTitle && (\n

      \n {line.variantTitle}\n

      \n )}\n

      \n Qty {line.quantity}\n

      \n
      \n \n
    • \n ))}\n
    \n\n
    \n {order.subtotal && (\n
    \n Subtotal\n \n
    \n )}\n {order.totalShipping && (\n
    \n Shipping\n \n
    \n )}\n {order.totalTax && (\n
    \n Tax\n \n
    \n )}\n
    \n Total\n \n
    \n
    \n\n {order.shippingAddress && (\n
    \n

    Shipping address

    \n

    \n {[order.shippingAddress.firstName, order.shippingAddress.lastName]\n .filter(Boolean)\n .join(' ')}\n
    \n {order.shippingAddress.address1}\n {order.shippingAddress.address2 && (\n <>\n
    \n {order.shippingAddress.address2}\n \n )}\n
    \n {[\n order.shippingAddress.city,\n order.shippingAddress.zoneCode,\n order.shippingAddress.zip,\n ]\n .filter(Boolean)\n .join(', ')}\n
    \n {order.shippingAddress.territoryCode}\n

    \n
    \n )}\n
    \n )\n}\n", "src/routes/shop.account.orders.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { Link, createFileRoute } from '@tanstack/react-router'\n\nimport { Money } from '#/components/shop/money'\nimport { getOrders } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account/orders/')({\n loader: () => getOrders({ data: { first: 50 } }),\n component: OrdersRoute,\n})\n\nfunction OrdersRoute() {\n const orders = Route.useLoaderData()\n if (orders.nodes.length === 0) {\n return (\n
    \n

    No orders yet.

    \n
    \n )\n }\n return (\n
    \n

    Orders

    \n
      \n {orders.nodes.map((order) => (\n
    • \n
      \n \n {order.name}\n \n

      \n {new Date(order.processedAt).toLocaleDateString()} ·{' '}\n {order.fulfillmentStatus ?? 'pending'} ·{' '}\n {order.financialStatus ?? '—'}\n

      \n
      \n \n
    • \n ))}\n
    \n
    \n )\n}\n", "src/routes/shop.account.tsx.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { Outlet, createFileRoute, redirect } from '@tanstack/react-router'\n\nimport { AccountNav } from '#/components/shop/account-nav'\nimport { getCustomer } from '#/server/shopify/customer.functions'\n\nexport const Route = createFileRoute('/shop/account')({\n beforeLoad: async ({ location }) => {\n const customer = await getCustomer()\n if (!customer) {\n throw redirect({\n to: '/shop/account/login',\n search: { redirect: location.href },\n })\n }\n return { customer }\n },\n loader: ({ context }) => ({ customer: context.customer }),\n component: AccountLayout,\n})\n\nfunction AccountLayout() {\n return (\n
    \n \n
    \n \n
    \n
    \n )\n}\n", "src/routes/shop.cart.tsx": "import { createFileRoute } from '@tanstack/react-router'\n\nimport { CartLineItem } from '#/components/shop/cart-line-item'\nimport { CartSummary } from '#/components/shop/cart-summary'\nimport { EmptyState } from '#/components/shop/empty-state'\nimport { CART_QUERY_KEY, useCart } from '#/hooks/use-cart'\nimport { getCart } from '#/server/shopify/cart.functions'\n\nexport const Route = createFileRoute('/shop/cart')({\n // QueryClient is always in context — the shopify add-on dependsOn tanstack-query.\n loader: ({ context }) =>\n context.queryClient.ensureQueryData({\n queryKey: CART_QUERY_KEY,\n queryFn: () => getCart(),\n }),\n component: CartRoute,\n})\n\nfunction CartRoute() {\n const { cart } = useCart()\n\n if (!cart || cart.lines.nodes.length === 0) {\n return (\n \n )\n }\n\n return (\n
    \n
    \n

    Cart

    \n
      \n {cart.lines.nodes.map((line) => (\n \n ))}\n
    \n
    \n \n
    \n )\n}\n", "src/routes/shop.collections.$handle.tsx": "import { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { ProductGrid } from '#/components/shop/product-grid'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport { getCollection } from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop/collections/$handle')({\n loader: async ({ params }) => {\n const collection = await getCollection({\n data: { handle: params.handle, first: 24 },\n })\n if (!collection) throw notFound()\n return { collection }\n },\n head: ({ loaderData }) => ({\n meta: loaderData\n ? [\n {\n title: loaderData.collection.seo.title ?? loaderData.collection.title,\n },\n {\n name: 'description',\n content:\n loaderData.collection.seo.description ??\n loaderData.collection.description ??\n '',\n },\n ]\n : [],\n }),\n component: CollectionRoute,\n})\n\nfunction CollectionRoute() {\n const { collection } = Route.useLoaderData()\n\n return (\n
    \n
    \n {collection.image && (\n \n )}\n
    \n

    \n {collection.title}\n

    \n {collection.description && (\n

    \n {collection.description}\n

    \n )}\n
    \n
    \n\n \n
    \n )\n}\n", "src/routes/shop.index.tsx": "import { createFileRoute } from '@tanstack/react-router'\n\nimport { ProductGrid } from '#/components/shop/product-grid'\nimport {\n getProducts,\n getShop,\n} from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop/')({\n loader: async () => {\n const [shop, page] = await Promise.all([\n getShop(),\n getProducts({ data: { first: 24, sortKey: 'BEST_SELLING' } }),\n ])\n return { shop, products: page.nodes }\n },\n component: ShopIndex,\n})\n\nfunction ShopIndex() {\n const { shop, products } = Route.useLoaderData()\n\n return (\n
    \n
    \n

    {shop.name}

    \n {shop.description && (\n

    \n {shop.description}\n

    \n )}\n
    \n \n
    \n )\n}\n", "src/routes/shop.pages.$handle.tsx": "import { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { getPage } from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop/pages/$handle')({\n loader: async ({ params }) => {\n const page = await getPage({ data: { handle: params.handle } })\n if (!page) throw notFound()\n return { page }\n },\n head: ({ loaderData }) => ({\n meta: loaderData\n ? [\n { title: loaderData.page.seo.title ?? loaderData.page.title },\n {\n name: 'description',\n content:\n loaderData.page.seo.description ??\n loaderData.page.bodySummary ??\n '',\n },\n ]\n : [],\n }),\n component: PageRoute,\n})\n\nfunction PageRoute() {\n const { page } = Route.useLoaderData()\n return (\n
    \n

    {page.title}

    \n \n
    \n )\n}\n", "src/routes/shop.policies.$handle.tsx": "import { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { getShopPolicy } from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop/policies/$handle')({\n loader: async ({ params }) => {\n const policy = await getShopPolicy({ data: { handle: params.handle } })\n if (!policy) throw notFound()\n return { policy }\n },\n head: ({ loaderData }) => ({\n meta: loaderData\n ? [{ title: loaderData.policy.title }]\n : [],\n }),\n component: PolicyRoute,\n})\n\nfunction PolicyRoute() {\n const { policy } = Route.useLoaderData()\n return (\n
    \n

    {policy.title}

    \n \n
    \n )\n}\n", "src/routes/shop.products.$handle.tsx": "import { useState } from 'react'\nimport { createFileRoute, notFound } from '@tanstack/react-router'\n\nimport { AddToCartButton } from '#/components/shop/add-to-cart-button'\nimport { Money } from '#/components/shop/money'\nimport { ShopImage } from '#/components/shop/shop-image'\nimport {\n VariantSelector,\n defaultSelectedOptions,\n findVariant,\n} from '#/components/shop/variant-selector'\nimport { getProduct } from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop/products/$handle')({\n loader: async ({ params }) => {\n const product = await getProduct({ data: { handle: params.handle } })\n if (!product) throw notFound()\n return { product }\n },\n head: ({ loaderData }) => ({\n meta: loaderData\n ? [\n { title: loaderData.product.seo.title ?? loaderData.product.title },\n loaderData.product.seo.description\n ? {\n name: 'description',\n content: loaderData.product.seo.description,\n }\n : { name: 'description', content: '' },\n ]\n : [],\n }),\n component: ProductDetailRoute,\n})\n\nfunction ProductDetailRoute() {\n const { product } = Route.useLoaderData()\n const [selected, setSelected] = useState(() =>\n defaultSelectedOptions(product),\n )\n const variant = findVariant(product.variants.nodes, selected)\n const heroImage = variant?.image ?? product.images.nodes[0] ?? null\n\n return (\n
    \n
    \n {heroImage && (\n \n )}\n {product.images.nodes.length > 1 && (\n
    \n {product.images.nodes.slice(0, 8).map((img) => (\n \n ))}\n
    \n )}\n
    \n\n
    \n
    \n

    \n {product.title}\n

    \n {variant && (\n

    \n \n

    \n )}\n
    \n\n \n\n \n\n {product.descriptionHtml && (\n \n )}\n
    \n
    \n )\n}\n", "src/routes/shop.search.tsx": "import { useState } from 'react'\nimport { createFileRoute, useNavigate } from '@tanstack/react-router'\nimport * as v from 'valibot'\n\nimport { ProductGrid } from '#/components/shop/product-grid'\nimport { searchProducts } from '#/server/shopify/catalog.functions'\n\nconst SearchSchema = v.object({\n q: v.optional(v.string(), ''),\n})\n\nexport const Route = createFileRoute('/shop/search')({\n validateSearch: (search) => v.parse(SearchSchema, search),\n loaderDeps: ({ search }) => ({ q: search.q }),\n loader: async ({ deps }) => {\n if (!deps.q.trim()) {\n return {\n q: '',\n products: [] as Awaited<\n ReturnType\n >['products'],\n totalCount: 0,\n }\n }\n const result = await searchProducts({\n data: { query: deps.q.trim(), first: 24 },\n })\n return { q: deps.q, products: result.products, totalCount: result.totalCount }\n },\n component: SearchRoute,\n})\n\nfunction SearchRoute() {\n const { q, products, totalCount } = Route.useLoaderData()\n const navigate = useNavigate({ from: Route.fullPath })\n const [draft, setDraft] = useState(q)\n\n return (\n
    \n

    Search

    \n\n {\n e.preventDefault()\n navigate({ search: { q: draft } })\n }}\n className=\"flex gap-2\"\n >\n setDraft(e.target.value)}\n placeholder=\"Search products\"\n autoFocus\n className=\"flex-1 rounded-full border border-[var(--storefront-line)] bg-transparent px-5 py-3 text-base focus:border-[var(--storefront-accent)] focus:outline-none\"\n />\n \n Search\n \n \n\n {q && (\n

    \n {totalCount} result{totalCount === 1 ? '' : 's'} for{' '}\n \"{q}\"\n

    \n )}\n\n {q && }\n
    \n )\n}\n", "src/routes/shop.tsx": "import { Link, Outlet, createFileRoute } from '@tanstack/react-router'\n\nimport '#/components/shop/shop.css'\nimport { getCollections } from '#/server/shopify/catalog.functions'\n\nexport const Route = createFileRoute('/shop')({\n loader: () => getCollections(),\n component: ShopLayout,\n})\n\nfunction ShopLayout() {\n const collections = Route.useLoaderData()\n\n return (\n
    \n
    \n
    \n \n Shop\n \n
    \n \n Search\n \n \n Cart\n \n
    \n
    \n\n
    \n \n
    \n \n
    \n
    \n
    \n
    \n )\n}\n", "src/server/shopify/cart.functions.ts": "import { createServerFn } from '@tanstack/react-start'\nimport { setResponseHeaders } from '@tanstack/react-start/server'\nimport * as v from 'valibot'\n\nimport { clearCartId, getCartId, setCartId } from '#/server/shopify/cookies'\nimport { shopifyServerFetch } from '#/server/shopify/storefront-client'\nimport {\n CART_CREATE_MUTATION,\n CART_DISCOUNT_CODES_UPDATE_MUTATION,\n CART_LINES_ADD_MUTATION,\n CART_LINES_REMOVE_MUTATION,\n CART_LINES_UPDATE_MUTATION,\n CART_QUERY,\n type CartCreateResult,\n type CartDetail,\n type CartDiscountCodesUpdateResult,\n type CartLinesAddResult,\n type CartLinesRemoveResult,\n type CartLinesUpdateResult,\n type CartQueryResult,\n type CartUserError,\n} from '#/lib/shopify/queries'\n\nexport class CartUserErrorsError extends Error {\n constructor(public readonly userErrors: Array) {\n super(userErrors.map((e) => e.message).join('\\n'))\n this.name = 'CartUserErrorsError'\n }\n}\n\nfunction throwIfUserErrors(errs: Array) {\n if (errs.length > 0) throw new CartUserErrorsError(errs)\n}\n\nfunction setCartResponseHeaders() {\n // Cart is per-user; do not edge-cache.\n setResponseHeaders(\n new Headers({ 'Cache-Control': 'private, no-store, must-revalidate' }),\n )\n}\n\nasync function fetchCartById(cartId: string): Promise {\n const result = await shopifyServerFetch({\n query: CART_QUERY,\n variables: { cartId },\n })\n return result.cart\n}\n\nexport const getCart = createServerFn({ method: 'GET' }).handler(\n async (): Promise => {\n setCartResponseHeaders()\n const cartId = getCartId()\n if (!cartId) return null\n\n const cart = await fetchCartById(cartId)\n // If Shopify pruned the cart (expired/abandoned), clear the stale cookie.\n if (!cart) clearCartId()\n return cart\n },\n)\n\nexport const addToCart = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n variantId: v.string(),\n quantity: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),\n }),\n )\n .handler(async ({ data }): Promise => {\n setCartResponseHeaders()\n const existingCartId = getCartId()\n\n if (!existingCartId) {\n const result = await shopifyServerFetch({\n query: CART_CREATE_MUTATION,\n variables: {\n input: {\n lines: [\n { merchandiseId: data.variantId, quantity: data.quantity },\n ],\n },\n },\n })\n throwIfUserErrors(result.cartCreate.userErrors)\n const cart = result.cartCreate.cart\n if (!cart) throw new Error('Shopify returned no cart after create.')\n setCartId(cart.id)\n return cart\n }\n\n const result = await shopifyServerFetch({\n query: CART_LINES_ADD_MUTATION,\n variables: {\n cartId: existingCartId,\n lines: [{ merchandiseId: data.variantId, quantity: data.quantity }],\n },\n })\n throwIfUserErrors(result.cartLinesAdd.userErrors)\n const cart = result.cartLinesAdd.cart\n // Existing cart was pruned between requests — recover by creating fresh.\n if (!cart) {\n clearCartId()\n const createResult = await shopifyServerFetch({\n query: CART_CREATE_MUTATION,\n variables: {\n input: {\n lines: [{ merchandiseId: data.variantId, quantity: data.quantity }],\n },\n },\n })\n throwIfUserErrors(createResult.cartCreate.userErrors)\n const newCart = createResult.cartCreate.cart\n if (!newCart)\n throw new Error('Shopify returned no cart after recovery create.')\n setCartId(newCart.id)\n return newCart\n }\n return cart\n })\n\nexport const updateCartLine = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n lineId: v.string(),\n quantity: v.pipe(v.number(), v.integer(), v.minValue(0)),\n }),\n )\n .handler(async ({ data }): Promise => {\n setCartResponseHeaders()\n const cartId = getCartId()\n if (!cartId) throw new Error('No cart exists to update.')\n\n const result = await shopifyServerFetch({\n query: CART_LINES_UPDATE_MUTATION,\n variables: {\n cartId,\n lines: [{ id: data.lineId, quantity: data.quantity }],\n },\n })\n throwIfUserErrors(result.cartLinesUpdate.userErrors)\n const cart = result.cartLinesUpdate.cart\n if (!cart) throw new Error('Shopify returned no cart after update.')\n return cart\n })\n\nexport const removeCartLine = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ lineId: v.string() }))\n .handler(async ({ data }): Promise => {\n setCartResponseHeaders()\n const cartId = getCartId()\n if (!cartId) throw new Error('No cart exists to remove from.')\n\n const result = await shopifyServerFetch({\n query: CART_LINES_REMOVE_MUTATION,\n variables: { cartId, lineIds: [data.lineId] },\n })\n throwIfUserErrors(result.cartLinesRemove.userErrors)\n const cart = result.cartLinesRemove.cart\n if (!cart) throw new Error('Shopify returned no cart after remove.')\n return cart\n })\n\nexport const applyDiscountCode = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ code: v.pipe(v.string(), v.minLength(1)) }))\n .handler(async ({ data }): Promise => {\n setCartResponseHeaders()\n const cartId = getCartId()\n if (!cartId) throw new Error('No cart exists to apply a discount to.')\n const result = await shopifyServerFetch({\n query: CART_DISCOUNT_CODES_UPDATE_MUTATION,\n variables: { cartId, discountCodes: [data.code] },\n })\n throwIfUserErrors(result.cartDiscountCodesUpdate.userErrors)\n const cart = result.cartDiscountCodesUpdate.cart\n if (!cart)\n throw new Error('Shopify returned no cart after discount update.')\n // Shopify silently drops invalid codes — surface that to the UI.\n const applied = cart.discountCodes.find(\n (c) => c.code.toLowerCase() === data.code.toLowerCase(),\n )\n if (!applied || !applied.applicable) {\n throw new CartUserErrorsError([\n {\n field: null,\n message: `Discount code \"${data.code}\" is not valid or not applicable to this cart.`,\n },\n ])\n }\n return cart\n })\n\nexport const removeDiscountCode = createServerFn({ method: 'POST' }).handler(\n async (): Promise => {\n setCartResponseHeaders()\n const cartId = getCartId()\n if (!cartId) throw new Error('No cart exists.')\n const result = await shopifyServerFetch({\n query: CART_DISCOUNT_CODES_UPDATE_MUTATION,\n variables: { cartId, discountCodes: [] },\n })\n throwIfUserErrors(result.cartDiscountCodesUpdate.userErrors)\n const cart = result.cartDiscountCodesUpdate.cart\n if (!cart) throw new Error('Shopify returned no cart after discount clear.')\n return cart\n },\n)\n", "src/server/shopify/catalog.functions.ts": "import { createServerFn } from '@tanstack/react-start'\nimport { setResponseHeaders } from '@tanstack/react-start/server'\nimport * as v from 'valibot'\n\nimport { shopifyServerFetch } from '#/server/shopify/storefront-client'\nimport {\n COLLECTION_QUERY,\n COLLECTIONS_QUERY,\n PAGE_QUERY,\n PRODUCT_QUERY,\n PRODUCTS_QUERY,\n SEARCH_QUERY,\n SHOP_POLICIES_QUERY,\n SHOP_QUERY,\n flattenPolicies,\n type CollectionDetail,\n type CollectionListItem,\n type CollectionQueryResult,\n type CollectionsQueryResult,\n type PageDetail,\n type PageQueryResult,\n type PolicySummary,\n type ProductDetail,\n type ProductListPage,\n type ProductQueryResult,\n type ProductsQueryResult,\n type ProductsQueryVariables,\n type SearchQueryResult,\n type ShopPoliciesQueryResult,\n type ShopPolicy,\n type ShopQueryResult,\n} from '#/lib/shopify/queries'\n\n/**\n * Edge-cache catalog responses for a few minutes. Catalog data doesn't change\n * often. CDN-Cache-Control is the runtime-portable header (Cloudflare, Vercel,\n * Netlify all read it); we also set a conservative public Cache-Control so\n * intermediaries don't cache aggressively without explicit opt-in.\n */\nfunction setBrowseCacheHeaders() {\n setResponseHeaders(\n new Headers({\n 'Cache-Control': 'public, max-age=0, must-revalidate',\n 'CDN-Cache-Control': 'public, max-age=300, stale-while-revalidate=600',\n }),\n )\n}\n\nconst productSortKeys = [\n 'BEST_SELLING',\n 'CREATED_AT',\n 'ID',\n 'PRICE',\n 'PRODUCT_TYPE',\n 'RELEVANCE',\n 'TITLE',\n 'UPDATED_AT',\n 'VENDOR',\n] as const\n\nconst collectionSortKeys = [\n 'BEST_SELLING',\n 'COLLECTION_DEFAULT',\n 'CREATED',\n 'ID',\n 'MANUAL',\n 'PRICE',\n 'RELEVANCE',\n 'TITLE',\n] as const\n\nexport const getShop = createServerFn({ method: 'GET' }).handler(\n async (): Promise => {\n setBrowseCacheHeaders()\n const data = await shopifyServerFetch({ query: SHOP_QUERY })\n return data.shop\n },\n)\n\nexport const getProducts = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n first: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 24),\n after: v.optional(v.nullable(v.string())),\n sortKey: v.optional(v.picklist(productSortKeys)),\n reverse: v.optional(v.boolean()),\n }),\n )\n .handler(async ({ data }): Promise => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n ProductsQueryResult,\n ProductsQueryVariables\n >({\n query: PRODUCTS_QUERY,\n variables: {\n first: data.first,\n after: data.after ?? null,\n sortKey: data.sortKey ?? null,\n reverse: data.reverse ?? null,\n },\n })\n return result.products\n })\n\nexport const getCollections = createServerFn({ method: 'GET' }).handler(\n async (): Promise> => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n CollectionsQueryResult,\n { first: number }\n >({\n query: COLLECTIONS_QUERY,\n variables: { first: 50 },\n })\n return result.collections.nodes\n },\n)\n\nexport const getProduct = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ handle: v.string() }))\n .handler(async ({ data }): Promise => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n ProductQueryResult,\n { handle: string }\n >({\n query: PRODUCT_QUERY,\n variables: { handle: data.handle },\n })\n return result.product\n })\n\nexport const getCollection = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n handle: v.string(),\n first: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 24),\n after: v.optional(v.nullable(v.string())),\n sortKey: v.optional(v.picklist(collectionSortKeys)),\n reverse: v.optional(v.boolean()),\n }),\n )\n .handler(async ({ data }): Promise => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n CollectionQueryResult,\n {\n handle: string\n first: number\n after: string | null\n sortKey: (typeof collectionSortKeys)[number] | null\n reverse: boolean | null\n }\n >({\n query: COLLECTION_QUERY,\n variables: {\n handle: data.handle,\n first: data.first,\n after: data.after ?? null,\n sortKey: data.sortKey ?? null,\n reverse: data.reverse ?? null,\n },\n })\n return result.collection\n })\n\nexport const getPage = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ handle: v.string() }))\n .handler(async ({ data }): Promise => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n PageQueryResult,\n { handle: string }\n >({\n query: PAGE_QUERY,\n variables: { handle: data.handle },\n })\n return result.page\n })\n\nexport const getShopPolicies = createServerFn({ method: 'GET' }).handler(\n async (): Promise> => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch({\n query: SHOP_POLICIES_QUERY,\n })\n return flattenPolicies(result.shop)\n },\n)\n\nexport const getShopPolicy = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ handle: v.string() }))\n .handler(\n async ({ data }): Promise<{ title: string; body: string; handle: string } | null> => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch({\n query: SHOP_POLICIES_QUERY,\n })\n const all = [\n result.shop.privacyPolicy,\n result.shop.refundPolicy,\n result.shop.termsOfService,\n result.shop.shippingPolicy,\n ].filter((p): p is NonNullable => p !== null)\n return all.find((p) => p.handle === data.handle) ?? null\n },\n )\n\nexport const searchProducts = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n query: v.pipe(v.string(), v.minLength(1)),\n first: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 24),\n after: v.optional(v.nullable(v.string())),\n }),\n )\n .handler(\n async ({\n data,\n }): Promise<{\n totalCount: number\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n products: ProductListPage['nodes']\n }> => {\n setBrowseCacheHeaders()\n const result = await shopifyServerFetch<\n SearchQueryResult,\n { query: string; first: number; after: string | null }\n >({\n query: SEARCH_QUERY,\n variables: {\n query: data.query,\n first: data.first,\n after: data.after ?? null,\n },\n })\n return {\n totalCount: result.search.totalCount,\n pageInfo: result.search.pageInfo,\n products: result.search.nodes,\n }\n },\n )\n", "src/server/shopify/cookies.ts": "import {\n deleteCookie,\n getCookie,\n setCookie,\n} from '@tanstack/react-start/server'\n\nexport const CART_COOKIE_NAME = 'tanstack_cart_id'\n\nconst ONE_YEAR_SECONDS = 60 * 60 * 24 * 365\n\nconst cartCookieOptions = () => ({\n path: '/' as const,\n maxAge: ONE_YEAR_SECONDS,\n sameSite: 'lax' as const,\n httpOnly: true,\n secure: import.meta.env.PROD,\n})\n\nexport function getCartId() {\n return getCookie(CART_COOKIE_NAME)\n}\n\nexport function setCartId(id: string) {\n setCookie(CART_COOKIE_NAME, id, cartCookieOptions())\n}\n\nexport function clearCartId() {\n deleteCookie(CART_COOKIE_NAME, { path: '/' })\n}\n", "src/server/shopify/customer-client.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport {\n buildSession,\n getDiscovery,\n refreshTokens,\n signSession,\n verifySession,\n type CustomerSession,\n} from '#/server/shopify/oauth'\nimport {\n clearSessionCookie,\n getSessionCookie,\n setSessionCookie,\n} from '#/server/shopify/customer-cookies'\n\nconst REFRESH_LEEWAY_MS = 60_000 // refresh if expiring within 60s\n\nclass CustomerAuthError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CustomerAuthError'\n }\n}\n\n/**\n * Read the current customer session, refreshing tokens if they're close to\n * expiry. Returns null when the user is not signed in.\n */\nexport async function getCustomerSession(): Promise {\n const cookie = getSessionCookie()\n if (!cookie) return null\n\n const session = await verifySession(cookie)\n if (!session) {\n clearSessionCookie()\n return null\n }\n\n if (session.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {\n return session\n }\n\n // Token expired or near-expiry — refresh.\n try {\n const refreshed = await refreshTokens(session.refreshToken)\n const next = buildSession(refreshed)\n setSessionCookie(await signSession(next))\n return next\n } catch {\n clearSessionCookie()\n return null\n }\n}\n\nexport async function requireCustomerSession(): Promise {\n const session = await getCustomerSession()\n if (!session) throw new CustomerAuthError('Not signed in')\n return session\n}\n\ntype CustomerFetchInput = {\n query: string\n variables?: TVariables\n session: CustomerSession\n}\n\ntype CustomerResponse = {\n data?: TData\n errors?: Array<{ message: string }>\n}\n\n/**\n * Authenticated GraphQL fetch against the Customer Account API. Caller must\n * pass a verified session (obtained via getCustomerSession).\n */\nexport async function customerFetch>(\n input: CustomerFetchInput,\n): Promise {\n const discovery = await getDiscovery()\n const res = await fetch(discovery.graphqlEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: input.session.accessToken,\n },\n body: JSON.stringify({ query: input.query, variables: input.variables }),\n })\n if (!res.ok) {\n throw new Error(\n `Customer Account API error: ${res.status} ${res.statusText}`,\n )\n }\n const json = (await res.json()) as CustomerResponse\n if (json.errors?.length) {\n throw new Error(json.errors.map((e) => e.message).join('\\n'))\n }\n if (!json.data) throw new Error('Customer Account API returned no data.')\n return json.data\n}\n", "src/server/shopify/customer-cookies.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport {\n deleteCookie,\n getCookie,\n setCookie,\n} from '@tanstack/react-start/server'\n\nconst SESSION_COOKIE = 'shopify_customer_session'\nconst FLIGHT_COOKIE = 'shopify_oauth_state'\n\nconst sessionCookieOptions = () => ({\n path: '/' as const,\n maxAge: 60 * 60 * 24 * 30, // 30 days\n sameSite: 'lax' as const,\n httpOnly: true,\n secure: import.meta.env.PROD,\n})\n\nconst flightCookieOptions = () => ({\n path: '/' as const,\n maxAge: 60 * 10, // 10 min\n sameSite: 'lax' as const,\n httpOnly: true,\n secure: import.meta.env.PROD,\n})\n\nexport function getSessionCookie() {\n return getCookie(SESSION_COOKIE)\n}\n\nexport function setSessionCookie(value: string) {\n setCookie(SESSION_COOKIE, value, sessionCookieOptions())\n}\n\nexport function clearSessionCookie() {\n deleteCookie(SESSION_COOKIE, { path: '/' })\n}\n\nexport function getFlightCookie() {\n return getCookie(FLIGHT_COOKIE)\n}\n\nexport function setFlightCookie(value: string) {\n setCookie(FLIGHT_COOKIE, value, flightCookieOptions())\n}\n\nexport function clearFlightCookie() {\n deleteCookie(FLIGHT_COOKIE, { path: '/' })\n}\n", "src/server/shopify/customer.functions.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { createServerFn } from '@tanstack/react-start'\nimport { setResponseHeaders } from '@tanstack/react-start/server'\nimport * as v from 'valibot'\n\nimport {\n customerFetch,\n getCustomerSession,\n requireCustomerSession,\n} from '#/server/shopify/customer-client'\nimport {\n clearFlightCookie,\n clearSessionCookie,\n getFlightCookie,\n setFlightCookie,\n setSessionCookie,\n} from '#/server/shopify/customer-cookies'\nimport {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n buildSession,\n createPkcePair,\n createState,\n exchangeCodeForTokens,\n signFlightState,\n signSession,\n verifyFlightState,\n} from '#/server/shopify/oauth'\nimport {\n CUSTOMER_ADDRESSES_QUERY,\n CUSTOMER_QUERY,\n ORDER_QUERY,\n ORDERS_QUERY,\n type CustomerAddress,\n type CustomerAddressesQueryResult,\n type CustomerProfile,\n type CustomerQueryResult,\n type OrderDetail,\n type OrderListItem,\n type OrderQueryResult,\n type OrdersQueryResult,\n} from '#/lib/shopify/customer-queries'\n\nfunction setPrivateHeaders() {\n setResponseHeaders(\n new Headers({ 'Cache-Control': 'private, no-store, must-revalidate' }),\n )\n}\n\nexport const startLogin = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n redirectAfter: v.optional(v.string(), '/shop/account'),\n }),\n )\n .handler(async ({ data }): Promise<{ authorizationUrl: string }> => {\n setPrivateHeaders()\n const { codeVerifier, codeChallenge } = await createPkcePair()\n const state = createState()\n const flight = await signFlightState({\n state,\n codeVerifier,\n redirectAfter: data.redirectAfter,\n })\n setFlightCookie(flight)\n const authorizationUrl = await buildAuthorizationUrl({ state, codeChallenge })\n return { authorizationUrl }\n })\n\nexport const completeLogin = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ code: v.string(), state: v.string() }))\n .handler(async ({ data }): Promise<{ redirectAfter: string }> => {\n setPrivateHeaders()\n const flightCookie = getFlightCookie()\n if (!flightCookie) throw new Error('OAuth flight cookie missing or expired.')\n const flight = await verifyFlightState(flightCookie)\n if (!flight) throw new Error('OAuth flight cookie failed verification.')\n if (flight.state !== data.state) throw new Error('OAuth state mismatch.')\n\n const tokens = await exchangeCodeForTokens({\n code: data.code,\n codeVerifier: flight.codeVerifier,\n })\n const session = buildSession(tokens)\n setSessionCookie(await signSession(session))\n clearFlightCookie()\n return { redirectAfter: flight.redirectAfter }\n })\n\nexport const logout = createServerFn({ method: 'POST' }).handler(\n async (): Promise<{ endSessionUrl: string | null }> => {\n setPrivateHeaders()\n const session = await getCustomerSession()\n clearSessionCookie()\n if (!session) return { endSessionUrl: null }\n return { endSessionUrl: await buildEndSessionUrl(session.idToken) }\n },\n)\n\nexport const getCustomer = createServerFn({ method: 'GET' }).handler(\n async (): Promise => {\n setPrivateHeaders()\n const session = await getCustomerSession()\n if (!session) return null\n const result = await customerFetch({\n query: CUSTOMER_QUERY,\n session,\n })\n return result.customer\n },\n)\n\nexport const getOrders = createServerFn({ method: 'POST' })\n .inputValidator(\n v.object({\n first: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 20),\n after: v.optional(v.nullable(v.string())),\n }),\n )\n .handler(\n async ({\n data,\n }): Promise<{\n nodes: Array\n pageInfo: { hasNextPage: boolean; endCursor: string | null }\n }> => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: ORDERS_QUERY,\n variables: { first: data.first, after: data.after ?? null },\n session,\n })\n return result.customer.orders\n },\n )\n\nexport const getOrder = createServerFn({ method: 'POST' })\n .inputValidator(v.object({ id: v.string() }))\n .handler(async ({ data }): Promise => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: ORDER_QUERY,\n variables: { id: data.id },\n session,\n })\n return result.order\n })\n\nexport const getAddresses = createServerFn({ method: 'GET' }).handler(\n async (): Promise<{\n addresses: Array\n defaultAddressId: string | null\n }> => {\n setPrivateHeaders()\n const session = await requireCustomerSession()\n const result = await customerFetch({\n query: CUSTOMER_ADDRESSES_QUERY,\n variables: { first: 50 },\n session,\n })\n return {\n addresses: result.customer.addresses.nodes,\n defaultAddressId: result.customer.defaultAddress?.id ?? null,\n }\n },\n)\n", "src/server/shopify/env.ts": "import * as v from 'valibot'\n\n/**\n * Defaults point to Shopify's public Hydrogen demo store so the storefront\n * works on first run with zero setup. Override by setting the matching env\n * vars in .env.local (or the deploy target's env config).\n *\n * Note: we don't rely on Vite's .env loading reaching `process.env` at runtime\n * — different runtimes handle that differently. Reading .env.local through\n * `process.env` works on most adapters; falling back to the demo values keeps\n * the first-run experience unbroken when it doesn't.\n */\nconst DEMO_STORE_DOMAIN = 'hydrogen-preview.myshopify.com'\nconst DEMO_API_VERSION = '2026-01'\nconst DEMO_PUBLIC_TOKEN = '3b580e70970c4528da70c98e097c2fa0'\n\nconst StorefrontEnvSchema = v.object({\n SHOPIFY_STORE_DOMAIN: v.pipe(v.string(), v.minLength(1)),\n SHOPIFY_STOREFRONT_API_VERSION: v.pipe(v.string(), v.minLength(1)),\n SHOPIFY_PUBLIC_STOREFRONT_TOKEN: v.optional(v.string()),\n SHOPIFY_PRIVATE_STOREFRONT_TOKEN: v.optional(v.string()),\n})\n\nconst CustomerEnvSchema = v.object({\n SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID: v.pipe(\n v.string(),\n v.minLength(1, 'SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID is required for customer accounts'),\n ),\n SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID: v.pipe(\n v.string(),\n v.minLength(1, 'SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID is required for customer accounts'),\n ),\n SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI: v.pipe(\n v.string(),\n v.url('SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI must be a valid URL'),\n ),\n SHOPIFY_SESSION_SECRET: v.pipe(\n v.string(),\n v.minLength(32, 'SHOPIFY_SESSION_SECRET must be at least 32 characters'),\n ),\n})\n\nlet cachedStorefront: v.InferOutput | null = null\nlet cachedCustomer: v.InferOutput | null = null\n\nfunction readEnv(name: string): string | undefined {\n // process.env on most server runtimes; falls back to undefined on edge\n // workers where process.env doesn't exist.\n if (typeof process !== 'undefined' && process.env) {\n const value = process.env[name]\n if (value && value.length > 0) return value\n }\n return undefined\n}\n\nexport function getStorefrontEnv() {\n if (cachedStorefront) return cachedStorefront\n cachedStorefront = v.parse(StorefrontEnvSchema, {\n SHOPIFY_STORE_DOMAIN: readEnv('SHOPIFY_STORE_DOMAIN') ?? DEMO_STORE_DOMAIN,\n SHOPIFY_STOREFRONT_API_VERSION:\n readEnv('SHOPIFY_STOREFRONT_API_VERSION') ?? DEMO_API_VERSION,\n SHOPIFY_PUBLIC_STOREFRONT_TOKEN:\n readEnv('SHOPIFY_PUBLIC_STOREFRONT_TOKEN') ?? DEMO_PUBLIC_TOKEN,\n SHOPIFY_PRIVATE_STOREFRONT_TOKEN: readEnv('SHOPIFY_PRIVATE_STOREFRONT_TOKEN'),\n })\n return cachedStorefront\n}\n\nexport function getCustomerEnv() {\n if (cachedCustomer) return cachedCustomer\n cachedCustomer = v.parse(CustomerEnvSchema, {\n SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID: readEnv('SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID'),\n SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID: readEnv('SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID'),\n SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI:\n readEnv('SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI') ??\n 'http://localhost:3000/shop/account/callback',\n SHOPIFY_SESSION_SECRET: readEnv('SHOPIFY_SESSION_SECRET'),\n })\n return cachedCustomer\n}\n\nexport function isCustomerAccountConfigured() {\n try {\n getCustomerEnv()\n return true\n } catch {\n return false\n }\n}\n", "src/server/shopify/oauth.ts.ejs": "<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>\nimport { getCustomerEnv } from '#/server/shopify/env'\n\n/**\n * OAuth 2.1 Authorization Code + PKCE flow for the Shopify Customer Account\n * API. We're a public client (browser + Start backend), so no client secret —\n * security comes from the PKCE verifier proof.\n *\n * No usable npm client exists for Customer Account API as of 2026-05; this is\n * a hand-rolled implementation using Web APIs only (portable across Node,\n * Workers, Vercel, Netlify, Bun, Deno).\n */\n\nconst SCOPE = 'openid email customer-account-api:full'\n\ntype DiscoveryDocument = {\n authorizationEndpoint: string\n tokenEndpoint: string\n endSessionEndpoint: string\n graphqlEndpoint: string\n}\n\nlet discoveryCache: DiscoveryDocument | null = null\n\nasync function fetchDiscovery(): Promise {\n if (discoveryCache) return discoveryCache\n const env = getCustomerEnv()\n const base = `https://shopify.com/${env.SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID}`\n const [oidcRes, customerRes] = await Promise.all([\n fetch(`${base}/.well-known/openid-configuration`),\n fetch(`${base}/.well-known/customer-account-api`),\n ])\n if (!oidcRes.ok || !customerRes.ok) {\n throw new Error(\n `Customer Account discovery failed (${oidcRes.status} / ${customerRes.status}). Verify SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID.`,\n )\n }\n const oidc = (await oidcRes.json()) as {\n authorization_endpoint: string\n token_endpoint: string\n end_session_endpoint: string\n }\n const customer = (await customerRes.json()) as {\n graphql_api: { endpoint: string }\n }\n discoveryCache = {\n authorizationEndpoint: oidc.authorization_endpoint,\n tokenEndpoint: oidc.token_endpoint,\n endSessionEndpoint: oidc.end_session_endpoint,\n graphqlEndpoint: customer.graphql_api.endpoint,\n }\n return discoveryCache\n}\n\nexport async function getDiscovery() {\n return fetchDiscovery()\n}\n\n/* ─── Base64url + crypto helpers (Web Crypto only) ──────────────────────── */\n\nfunction bufferToBase64Url(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer)\n let str = ''\n for (let i = 0; i < bytes.byteLength; i++) {\n str += String.fromCharCode(bytes[i]!)\n }\n return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction base64UrlEncode(input: string): string {\n return bufferToBase64Url(new TextEncoder().encode(input).buffer as ArrayBuffer)\n}\n\nfunction base64UrlDecode(input: string): string {\n const padded = input.replace(/-/g, '+').replace(/_/g, '/')\n const padding = (4 - (padded.length % 4)) % 4\n return atob(padded + '='.repeat(padding))\n}\n\nfunction randomBytes(n: number): Uint8Array {\n const arr = new Uint8Array(n)\n crypto.getRandomValues(arr)\n return arr\n}\n\nfunction randomString(byteLength: number): string {\n return bufferToBase64Url(randomBytes(byteLength).buffer as ArrayBuffer)\n}\n\nasync function sha256(input: string): Promise {\n return crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))\n}\n\n/* ─── PKCE ──────────────────────────────────────────────────────────────── */\n\nexport type PkcePair = { codeVerifier: string; codeChallenge: string }\n\nexport async function createPkcePair(): Promise {\n const codeVerifier = randomString(48)\n const codeChallenge = bufferToBase64Url(await sha256(codeVerifier))\n return { codeVerifier, codeChallenge }\n}\n\nexport function createState(): string {\n return randomString(24)\n}\n\n/* ─── Authorize / token / logout URLs ───────────────────────────────────── */\n\nexport async function buildAuthorizationUrl(input: {\n state: string\n codeChallenge: string\n}): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const url = new URL(discovery.authorizationEndpoint)\n url.searchParams.set('client_id', env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID)\n url.searchParams.set('scope', SCOPE)\n url.searchParams.set('response_type', 'code')\n url.searchParams.set('redirect_uri', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI)\n url.searchParams.set('state', input.state)\n url.searchParams.set('code_challenge', input.codeChallenge)\n url.searchParams.set('code_challenge_method', 'S256')\n return url.toString()\n}\n\ntype TokenResponse = {\n access_token: string\n refresh_token: string\n id_token: string\n expires_in: number\n token_type: 'Bearer'\n}\n\nexport async function exchangeCodeForTokens(input: {\n code: string\n codeVerifier: string\n}): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const body = new URLSearchParams({\n grant_type: 'authorization_code',\n client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,\n code: input.code,\n redirect_uri: env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI,\n code_verifier: input.codeVerifier,\n })\n const res = await fetch(discovery.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,\n },\n body,\n })\n if (!res.ok) {\n throw new Error(\n `Token exchange failed: ${res.status} ${res.statusText} ${await res.text()}`,\n )\n }\n return (await res.json()) as TokenResponse\n}\n\nexport async function refreshTokens(refreshToken: string): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,\n refresh_token: refreshToken,\n })\n const res = await fetch(discovery.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,\n },\n body,\n })\n if (!res.ok) {\n throw new Error(`Token refresh failed: ${res.status} ${res.statusText}`)\n }\n return (await res.json()) as TokenResponse\n}\n\nexport async function buildEndSessionUrl(idToken: string): Promise {\n const env = getCustomerEnv()\n const discovery = await fetchDiscovery()\n const url = new URL(discovery.endSessionEndpoint)\n url.searchParams.set('id_token_hint', idToken)\n url.searchParams.set(\n 'post_logout_redirect_uri',\n new URL('/', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).toString(),\n )\n return url.toString()\n}\n\n/* ─── Signed session cookie helpers (HS256) ─────────────────────────────── */\n\nlet cachedHmacKey: CryptoKey | null = null\n\nasync function getHmacKey(): Promise {\n if (cachedHmacKey) return cachedHmacKey\n const env = getCustomerEnv()\n cachedHmacKey = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(env.SHOPIFY_SESSION_SECRET),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign', 'verify'],\n )\n return cachedHmacKey\n}\n\nexport type CustomerSession = {\n accessToken: string\n refreshToken: string\n idToken: string\n expiresAt: number\n}\n\nexport async function signSession(session: CustomerSession): Promise {\n const key = await getHmacKey()\n const payload = base64UrlEncode(JSON.stringify(session))\n const sig = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(payload),\n )\n return `${payload}.${bufferToBase64Url(sig)}`\n}\n\nexport async function verifySession(\n signed: string,\n): Promise {\n const [payload, sig] = signed.split('.')\n if (!payload || !sig) return null\n const key = await getHmacKey()\n const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))\n const ok = await crypto.subtle.verify(\n 'HMAC',\n key,\n sigBytes,\n new TextEncoder().encode(payload),\n )\n if (!ok) return null\n try {\n return JSON.parse(base64UrlDecode(payload)) as CustomerSession\n } catch {\n return null\n }\n}\n\nexport function buildSession(token: TokenResponse): CustomerSession {\n return {\n accessToken: token.access_token,\n refreshToken: token.refresh_token,\n idToken: token.id_token,\n expiresAt: Date.now() + token.expires_in * 1000,\n }\n}\n\nexport type OAuthFlightState = {\n state: string\n codeVerifier: string\n redirectAfter: string\n}\n\nexport async function signFlightState(\n state: OAuthFlightState,\n): Promise {\n const key = await getHmacKey()\n const payload = base64UrlEncode(JSON.stringify(state))\n const sig = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(payload),\n )\n return `${payload}.${bufferToBase64Url(sig)}`\n}\n\nexport async function verifyFlightState(\n signed: string,\n): Promise {\n const [payload, sig] = signed.split('.')\n if (!payload || !sig) return null\n const key = await getHmacKey()\n const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))\n const ok = await crypto.subtle.verify(\n 'HMAC',\n key,\n sigBytes,\n new TextEncoder().encode(payload),\n )\n if (!ok) return null\n try {\n return JSON.parse(base64UrlDecode(payload)) as OAuthFlightState\n } catch {\n return null\n }\n}\n", "src/server/shopify/storefront-client.ts": "import { getStorefrontEnv } from './env'\n\ntype ShopifyFetchInput = {\n query: string\n variables?: TVariables\n /**\n * Optional buyer IP, forwarded to Shopify's bot-protection headers.\n * Only meaningful with the private token.\n */\n buyerIp?: string\n}\n\ntype ShopifyResponse = {\n data?: TData\n errors?: Array<{ message: string; locations?: unknown; path?: unknown }>\n}\n\nexport class ShopifyError extends Error {\n constructor(\n message: string,\n public readonly errors?: ShopifyResponse['errors'],\n ) {\n super(message)\n this.name = 'ShopifyError'\n }\n}\n\n/**\n * Read the buyer's IP from a Request's headers in a runtime-portable way.\n * Cloudflare uses `cf-connecting-ip`; Vercel/Netlify/Node behind a proxy use\n * `x-forwarded-for`. Returns undefined if neither header is present.\n */\nexport function getBuyerIp(headers: Headers): string | undefined {\n const cf = headers.get('cf-connecting-ip')\n if (cf) return cf\n const fwd = headers.get('x-forwarded-for')\n if (fwd) return fwd.split(',')[0]?.trim()\n return undefined\n}\n\n/**\n * Server-side Storefront API client. Prefers the private access token (higher\n * rate limits + buyer-IP forwarding); falls back to the public token so the\n * default demo store works with zero setup.\n *\n * Use in route loaders and server functions only — never in browser code.\n */\nexport async function shopifyServerFetch<\n TData,\n TVariables = Record,\n>(input: ShopifyFetchInput): Promise {\n const env = getStorefrontEnv()\n const usingPrivate = Boolean(env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN)\n const token =\n env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN ?? env.SHOPIFY_PUBLIC_STOREFRONT_TOKEN\n\n if (!token) {\n throw new ShopifyError(\n 'Shopify Storefront token missing. Set SHOPIFY_PUBLIC_STOREFRONT_TOKEN in .env.local.',\n )\n }\n\n const headers: Record = {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n }\n if (usingPrivate) {\n headers['Shopify-Storefront-Private-Token'] = token\n if (input.buyerIp) headers['Shopify-Storefront-Buyer-IP'] = input.buyerIp\n } else {\n headers['X-Shopify-Storefront-Access-Token'] = token\n }\n\n const url = `https://${env.SHOPIFY_STORE_DOMAIN}/api/${env.SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({ query: input.query, variables: input.variables }),\n })\n\n if (!response.ok) {\n throw new ShopifyError(\n `Shopify API error: ${response.status} ${response.statusText}`,\n )\n }\n\n const json = (await response.json()) as ShopifyResponse\n\n if (json.errors?.length) {\n throw new ShopifyError(\n json.errors.map((e) => e.message).join('\\n'),\n json.errors,\n )\n }\n\n if (!json.data) {\n throw new ShopifyError('Shopify API returned no data and no errors.')\n }\n\n return json.data\n}\n" }, "deletedFiles": [], "smallLogo": "\n" } satisfies AddOnCompiled