diff --git a/.changeset/calm-commerce-server.md b/.changeset/calm-commerce-server.md new file mode 100644 index 00000000..67c0705a --- /dev/null +++ b/.changeset/calm-commerce-server.md @@ -0,0 +1,9 @@ +--- +'@godaddy/commerce-server': minor +--- + +Add an Express server package for GoDaddy Commerce catalog, cart, and hosted checkout routes, with production API defaults and host-owned configuration and attribution. + +Require host-approved checkout return URLs and keep non-catalog pricing in trusted server helpers. Recover saved carts when the Orders API reports a missing or completed draft. + +Read completed orders through the authorized Orders REST API with the `commerce.order:read` scope. diff --git a/.changeset/quiet-storefront-templates.md b/.changeset/quiet-storefront-templates.md new file mode 100644 index 00000000..8426fa72 --- /dev/null +++ b/.changeset/quiet-storefront-templates.md @@ -0,0 +1,5 @@ +--- +"@godaddy/commerce-storefront": minor +--- + +Add an opinionated React storefront package with catalog, verified variant selection, a shared cart drawer, and optional hosted checkout handoff. Ship scoped, layer-free CSS compatible with Tailwind v3 host builds, TypeScript response contracts, and an independent consumer example. Keep host application content mounted when commerce configuration is unavailable. diff --git a/AGENTS.md b/AGENTS.md index 7c8cf103..5dd80d7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,14 @@ biome-config-godaddy (packages/biome-config-godaddy) - typecheck: tsc --noEmit - test: vitest run +@godaddy/commerce-server (packages/commerce-server) +- build: tsdown +- typecheck: tsc --noEmit +- lint: biome check src +- lint:fix: biome check --write src +- test: vitest run +- prepublishOnly: pnpm build + @godaddy/localizations (packages/localizations) - dev: tsdown --watch - build: tsdown @@ -129,6 +137,11 @@ Packages (top-level purpose) - React component library for checkout flows; integrates with commerce APIs - Uses tsdown for TS build and Tailwind CLI v4 for CSS build; Vitest for tests; Vite preview - Depends on @godaddy/localizations +- @godaddy/commerce-storefront + - React catalog, product details, and cart components using a same-origin Commerce API +- @godaddy/commerce-server + - Express 5 routers for the storefront API, hosted checkout, and order lookup + - Uses tsdown, Vitest, and Biome; usable independently of the storefront package - @godaddy/localizations - Localized strings for checkout components; TS build via tsdown @@ -275,7 +288,26 @@ D. @godaddy/react - Uses path alias "@/*" for src - If adding components, follow existing patterns in src/components/checkout/** and src/components/ui/** -E. @godaddy/localizations +E. @godaddy/commerce-storefront +- Opinionated React storefront; fixed same-origin `/api/commerce` server contract documented in packages/commerce-storefront/docs/server-api.md. +- Peers: React/React DOM 18 or 19, React Router 7 or 8.3+, TanStack Query 5. Host owns router/query providers; CommerceStorefront owns the cart provider and drawer. +- Build: tsdown plus local Tailwind CLI, CSS scoping, and layer removal in declared order; styles exported as ./styles.css. Artifact tests process the output through Tailwind v3 to verify host compatibility. No host Tailwind setup or global reset. +- Test: build first, then Vitest (behavior plus compiled artifact checks). Commands: build, typecheck, lint, test. +- Example: examples/commerce-storefront, port 5184, development-only in-memory server; production build needs real API routes. +- Keep credentials, merchant provisioning and platform configuration out of this client package. No dependency on the separate commerce web-component runtime. + +F. @godaddy/commerce-server +- Mount `createCommerceRouter()` at `/api/commerce` after `express.json()`. `createCommerceCatalogRouter()` installs config/catalog/cart routes; `createGoDaddyPaymentsRouter()` installs checkout/order-status routes. +- Hosts can supply `CommerceConfiguration`. The default runtime reader uses server environment variables for credentials, store/channel IDs, currency, and checkout flags; it does not read files. The API defaults to `https://api.godaddy.com`; an explicit server-controlled `apiBaseUrl` option supports alternate origins without embedding environment-specific hosts. Keep this package server-only. +- Hosts own deployment-specific configuration loading, provisioning readiness, retries, and optional `sourceApp`/`owner` attribution. Shipping options use the hosted checkout API shape; omit them to use the store configuration. +- HTTP checkout accepts only cart/SKU inputs; server-owned non-catalog prices use the trusted helper. Configure `checkoutReturnUrls` with exact HTTPS cancel/success destinations; without it, HTTP checkout is disabled. Only success URLs may add an `orderId` query parameter. +- Exported helpers `createCheckoutSession()` and `getOrderStatus()` support in-process server callers. Order lookup uses the store-scoped Orders REST API with OAuth scope `commerce.order:read`, verifies the order/store/channel binding, and returns the API's payment status. A checkout redirect alone is not proof of payment; hosts must authorize caller access to each order. +- Source alias `@/*` maps to `src/*` in TypeScript and Vitest. tsdown resolves it when bundling JavaScript and declarations; consumers need no alias configuration. When changing module resolution, verify a packed consumer outside the workspace. +- Validate supplied `X-Commerce-Scope` headers before catalog/cart/checkout calls. It guards against stale bindings and is not authorization. Hosts own authentication and authorization. +- Cart mutations return a refreshed cart. Keep URL cart/item IDs authoritative, allowlist PATCH fields, and only clear carts for explicit missing/expired-order errors; upstream authentication or transport failures must preserve saved carts. +- Commands: `pnpm --filter @godaddy/commerce-server build`, `typecheck`, `lint`, and `test`. See packages/commerce-server/README.md and packages/commerce-storefront/docs/server-api.md for integration details. + +G. @godaddy/localizations - Purpose: Localization bundles for checkout UI - Structure: src/.ts with a shared object shape; exported via src/index.ts - Scripts: dev/build/typecheck diff --git a/README.md b/README.md index 09cfd443..62848256 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ This monorepo contains the following packages: | [`biome-config-godaddy`](/packages/biome-config-godaddy) | Fast Rust-based alternative to ESLint and Prettier using Biome | [![npm](https://img.shields.io/npm/v/biome-config-godaddy.svg)](https://www.npmjs.com/package/biome-config-godaddy) | | [`@godaddy/app-connect`](/packages/app-connect) | Platform integration tools for GoDaddy apps | [![npm](https://img.shields.io/npm/v/@godaddy/app-connect.svg)](https://www.npmjs.com/package/@godaddy/app-connect) | | [`@godaddy/react`](/packages/react) | React components and commerce API integration | [![npm](https://img.shields.io/npm/v/@godaddy/react.svg)](https://www.npmjs.com/package/@godaddy/react) | +| [@godaddy/commerce-storefront](packages/commerce-storefront) | Opinionated React catalog, product, and cart templates; [consumer example](examples/commerce-storefront) | Unreleased | +| [@godaddy/commerce-server](packages/commerce-server) | Express APIs for Commerce catalog, carts, hosted checkout, and order lookup | Unreleased | + +`@godaddy/commerce-server` implements the same-origin API used by `@godaddy/commerce-storefront`. The packages can also be used independently with a custom client or server; see the [server integration guide](packages/commerce-server/README.md) and [storefront API contract](packages/commerce-storefront/docs/server-api.md). ## Why GoDaddy JavaScript? diff --git a/examples/commerce-storefront/README.md b/examples/commerce-storefront/README.md new file mode 100644 index 00000000..4a7563b9 --- /dev/null +++ b/examples/commerce-storefront/README.md @@ -0,0 +1,19 @@ +# Independent commerce storefront example + +This React Router application consumes the compiled `@godaddy/commerce-storefront` package and its shipped CSS. It runs as a standalone application without Tailwind configuration. + +Run from the repository root with Node 24: + +```sh +pnpm install +pnpm --filter @godaddy/commerce-storefront build +pnpm --filter commerce-storefront-example dev +``` + +Open . The Vite development server serves a demonstration catalog and in-memory cart at `/api/commerce`. Try adding the mug, changing quantities, removing items, and selecting the blue or sold-out clay tote. Reloading the browser restores a saved cart while the demo server remains running. Restarting the server expires it. + +This mock is for local UI demonstration only. It creates no Commerce orders or payments and is not a production server implementation. Checkout is intentionally disabled. The production build verifies bundling; it does not include the mock API, so a deployed build needs an implementation of the [server contract](../../packages/commerce-storefront/docs/server-api.md). + +```sh +pnpm --filter commerce-storefront-example build +``` diff --git a/examples/commerce-storefront/index.html b/examples/commerce-storefront/index.html new file mode 100644 index 00000000..a09828dc --- /dev/null +++ b/examples/commerce-storefront/index.html @@ -0,0 +1,2 @@ + +Commerce storefront example
diff --git a/examples/commerce-storefront/main.tsx b/examples/commerce-storefront/main.tsx new file mode 100644 index 00000000..a5b491e2 --- /dev/null +++ b/examples/commerce-storefront/main.tsx @@ -0,0 +1,27 @@ +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router'; +import { Catalog, CartButton, CommerceStorefront, ProductDetails } from '@godaddy/commerce-storefront'; +import '@godaddy/commerce-storefront/styles.css'; +import './styles.css'; + +const client = new QueryClient(); +createRoot(document.getElementById('root')!).render( + + + + Skip to products +
Field & Form
+
+

Demo catalog and in-memory cart. No orders or payments are created.

+ + } /> + } /> + } /> + +
+
Independent React app · No Tailwind configuration
+
+
+
+); diff --git a/examples/commerce-storefront/package.json b/examples/commerce-storefront/package.json new file mode 100644 index 00000000..d101012d --- /dev/null +++ b/examples/commerce-storefront/package.json @@ -0,0 +1,24 @@ +{ + "name": "commerce-storefront-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5184 --strictPort", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@godaddy/commerce-storefront": "workspace:*", + "@tanstack/react-query": "^5.66.0", + "react": "^19", + "react-dom": "^19", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "typescript": "~5.7.3", + "vite": "^6.4.1" + } +} diff --git a/examples/commerce-storefront/styles.css b/examples/commerce-storefront/styles.css new file mode 100644 index 00000000..ef012007 --- /dev/null +++ b/examples/commerce-storefront/styles.css @@ -0,0 +1,8 @@ +body { margin: 0; background: #faf9f6; color: #242c28; font-family: system-ui, sans-serif; } +.site-header { display:flex; align-items:center; justify-content:space-between; padding:24px max(16px, 5vw); border-bottom:1px solid #deded8; } +.site-header > a { font-family: Georgia, serif; font-size: 24px; color: inherit; text-decoration: none; } +main { max-width:1200px; padding:24px 16px 64px; margin:auto; } +.demo-note { padding:12px; background:#e9efe9; border-radius:8px; font-size:14px; } +footer { padding:24px; border-top:1px solid #deded8; font-size:14px; } +.skip-link { position:absolute; top:-100px; padding:16px; background:white; z-index:100; } +.skip-link:focus { top:0; } diff --git a/examples/commerce-storefront/tsconfig.json b/examples/commerce-storefront/tsconfig.json new file mode 100644 index 00000000..68481738 --- /dev/null +++ b/examples/commerce-storefront/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true + }, + "include": [ + "*.ts", + "*.tsx" + ] +} diff --git a/examples/commerce-storefront/vite.config.ts b/examples/commerce-storefront/vite.config.ts new file mode 100644 index 00000000..885be445 --- /dev/null +++ b/examples/commerce-storefront/vite.config.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto'; +import { defineConfig, type Plugin } from 'vite'; +import type { CartOrder, SKU, SKUGroup } from '@godaddy/commerce-storefront'; + +const money = (value: number) => ({ value, currencyCode: 'USD' }); +const sku = (id: string, price: number, quantity = 20): SKU => ({ + id, prices: { edges: [{ node: { value: money(price) } }] }, + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity } }] }, +}); +const mug = sku('mug', 2400); +const blue = sku('tote-blue', 3600); +const clay = sku('tote-clay', 3800, 0); +const products: SKUGroup[] = [ + { id: 'mug', label: 'Studio mug', description: 'A sturdy ceramic mug for slow mornings.', priceRange: { min: 2400, max: 2400 }, skus: { edges: [{ node: mug }], totalCount: 1 } }, + { id: 'tote', label: 'Market tote', description: 'Choose a color for your everyday carry.', priceRange: { min: 3600, max: 3800 }, attributes: { edges: [{ node: { id: 'color', name: 'color', label: 'Color', values: { edges: [{ node: { name: 'blue', label: 'Blue' } }, { node: { name: 'clay', label: 'Clay' } }] } } }] }, skus: { edges: [{ node: blue }, { node: clay }], totalCount: 2 } }, +]; + +// Demonstration only. This is neither a production server nor a Commerce API emulator. +function demoApi(): Plugin { + const carts = new Map(); + return { name: 'storefront-demo-api', configureServer(server) { + server.middlewares.use('/api/commerce', async (req, res, next) => { + try { + const url = new URL(req.url ?? '/', 'http://localhost'); + const send = (value: unknown, status = 200) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; + if (url.pathname === '/config') return send({ cartScope: 'demo-v1', currencyCode: 'USD' }); + if (url.pathname === '/products') return send({ skuGroups: { edges: products.map(node => ({ node })), pageInfo: { hasNextPage: false } } }); + if (url.pathname.startsWith('/products/')) { + const product = products.find(item => item.id === url.pathname.split('/')[2]); + const color = url.searchParams.get('attributeValues'); + return send({ skuGroup: product && color ? { ...product, skus: { edges: color === 'blue' ? [{ node: blue }] : color === 'clay' ? [{ node: clay }] : [], totalCount: 1 } } : product ?? null }); + } + if (!url.pathname.startsWith('/cart')) return next(); + if (req.headers['x-commerce-scope'] !== 'demo-v1') return send({ error: 'Store changed. Reload the page.' }, 409); + let body = ''; + for await (const chunk of req) body += chunk; + const input = body ? JSON.parse(body) : {}; + const [, , cartId, , itemId] = url.pathname.split('/'); + let cart = carts.get(cartId); + if (req.method === 'GET') return send(cart ? { cart } : { error: 'Cart expired.' }, cart ? 200 : 404); + if (!cart && url.pathname !== '/cart') return send({ error: 'Cart expired.' }, 404); + cart ??= { id: randomUUID(), lineItems: [] }; + if (req.method === 'POST') { + for (const item of input.lineItems ?? [input]) { + const selected = [mug, blue, clay].find(s => s.id === item.skuId); + if (!selected || selected === clay || !Number.isInteger(item.quantity) || item.quantity < 1) return send({ error: 'This variant is unavailable.' }, 400); + const price = selected.prices!.edges![0]!.node!.value!.value!; + const existing = cart.lineItems!.find(line => line.skuId === item.skuId); + if (existing) { existing.quantity = (existing.quantity ?? 0) + item.quantity; existing.totals = { subTotal: money(existing.quantity! * price) }; } + else cart.lineItems!.push({ id: randomUUID(), skuId: item.skuId, name: item.name, quantity: item.quantity, totals: { subTotal: money(price * item.quantity) } }); + } + } + if (req.method === 'PATCH') { + const item = cart.lineItems!.find(line => line.id === itemId); + if (!item || !Number.isInteger(input.quantity) || input.quantity < 1) return send({ error: 'Invalid quantity.' }, 400); + const unit = item.totals!.subTotal!.value! / item.quantity!; + item.quantity = input.quantity; item.totals = { subTotal: money(unit * input.quantity) }; + } + if (req.method === 'DELETE') cart.lineItems = cart.lineItems!.filter(line => line.id !== itemId); + const total = cart.lineItems!.reduce((sum, item) => sum + (item.totals?.subTotal?.value ?? 0), 0); + cart.totals = { subTotal: money(total), total: money(total) }; + carts.set(cart.id!, cart); send({ cart }); + } catch { res.statusCode = 500; res.end(JSON.stringify({ error: 'Demo server failed.' })); } + }); + } }; +} +export default defineConfig({ plugins: [demoApi()] }); diff --git a/packages/commerce-server/LICENSE.md b/packages/commerce-server/LICENSE.md new file mode 100644 index 00000000..fd882b91 --- /dev/null +++ b/packages/commerce-server/LICENSE.md @@ -0,0 +1,3 @@ +Copyright 2026 GoDaddy.com Operating Company, LLC + +Licensed under the MIT License. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md new file mode 100644 index 00000000..c1741082 --- /dev/null +++ b/packages/commerce-server/README.md @@ -0,0 +1,57 @@ +# @godaddy/commerce-server + +An opinionated Express router for GoDaddy Commerce catalog, cart, and hosted checkout APIs. + +```ts +import express from 'express'; +import { createCommerceRouter, createRuntimeCommerceConfiguration } from '@godaddy/commerce-server'; + +const app = express(); +app.use(express.json()); +app.use('/api/commerce', createCommerceRouter({ + configuration: createRuntimeCommerceConfiguration(), + checkoutReturnUrls: { + returnUrls: ['https://shop.example.com/shop'], + successUrls: ['https://shop.example.com/checkout/success'], + }, +})); +``` + +The default configuration reads these **server-only environment variables** on each request: + +- `GODADDY_OAUTH_CLIENT_ID` and `GODADDY_OAUTH_CLIENT_SECRET` +- `GODADDY_STORE_ID` and `GODADDY_CHANNEL_ID` +- `GODADDY_CURRENCY_CODE` +- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. + +The API origin defaults to `https://api.godaddy.com`. The package does not load files, provision merchants, or assign application attribution. Hosts own these concerns and any readiness checks or retries before invoking Commerce. + +## Host configuration + +For an alternate API origin, pass an explicit server-controlled option. It must be an HTTPS origin without credentials, a path, query, or fragment. The host is responsible for trusting the destination, which receives OAuth credentials. Request bodies never select the API origin or attribution. + +```ts +const configuration = createRuntimeCommerceConfiguration({ + apiBaseUrl: process.env.COMMERCE_API_ORIGIN, // undefined uses production + sourceApp: process.env.COMMERCE_SOURCE_APP, + owner: process.env.COMMERCE_ORDER_OWNER, +}); +``` + +`apiBaseUrl` controls catalog, order, and OAuth requests; checkout uses the corresponding `checkout.commerce.` subdomain. There is no built-in list of alternate environments. `sourceApp` and `owner` are optional host-owned attribution values: checkout uses both, while draft orders use `owner`. Supply values required by your Commerce integration; the package omits them by default. + +Hosts with their own configuration service can implement `CommerceConfiguration` directly. `read()` returns `clientId`, `clientSecret`, `storeId`, `channelId`, `currencyCode`, `apiBaseUrl`, and optional attribution. `readCheckout()` returns the checkout flags and optional shipping settings. Return validated, ready-to-use settings from one consistent binding. Both functions run on the server; credentials must never reach browser code. + +## Routers and helpers + +`createCommerceCatalogRouter(configuration)` installs catalog, public configuration, and cart routes. `createGoDaddyPaymentsRouter(configuration, checkoutReturnUrls)` installs checkout and read-only order-status routes without requiring catalog UI. `createCommerceRouter({ configuration })` installs both by default or accepts explicit feature flags. + +The HTTP checkout route accepts an existing `draftOrderId` or a direct `skuId`; Commerce resolves catalog prices. It rejects `lineItemData`. For non-catalog charges such as fixed-price deposits, calculate the amount on the server and call `createCheckoutSession(params, configuration)` directly. Never forward browser-supplied prices to this trusted helper. Server callers can also use `getOrderStatus(orderId, configuration)` without an HTTP loopback. + +## Checkout return destinations + +Configure `checkoutReturnUrls` on the router using trusted deployment settings. Both lists contain complete absolute HTTPS URLs; scheme, origin, port, path, and configured query parameters must match. Success URLs may add a single `orderId` query parameter. Additional query parameters, fragments, credentials, relative URLs, and unlisted destinations are rejected. Configure separate cancel and success destinations as shown above; do not derive the allowlist from request headers or request bodies. + +Without this policy, HTTP checkout returns 503 before creating a session. Invalid request destinations return 400. This applies to both router presets that expose checkout. Trusted in-process callers of `createCheckoutSession()` own their return URLs and must construct or validate them server-side. + +A return from hosted checkout is not proof of payment. The order-status route uses the authorized Orders REST API, which supports completed orders, and returns its payment status (for example `PAID` or `PENDING`; `unknown` if absent). The server OAuth client must be granted `commerce.order:read`. The helper verifies the returned order ID, store, and channel and returns a limited summary without customer contact data. Hosts must authenticate callers and authorize access to each requested order before exposing this route. diff --git a/packages/commerce-server/biome.json b/packages/commerce-server/biome.json new file mode 100644 index 00000000..b90dbd90 --- /dev/null +++ b/packages/commerce-server/biome.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 110 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "single" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/packages/commerce-server/package.json b/packages/commerce-server/package.json new file mode 100644 index 00000000..8c05f18d --- /dev/null +++ b/packages/commerce-server/package.json @@ -0,0 +1,51 @@ +{ + "name": "@godaddy/commerce-server", + "version": "0.0.0", + "description": "Opinionated Express server integration for GoDaddy Commerce", + "type": "module", + "license": "MIT", + "author": "GoDaddy.com Operating Company, LLC", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "biome check src", + "lint:fix": "biome check --write src", + "prepublishOnly": "pnpm build" + }, + "peerDependencies": { + "express": "^5.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.2", + "@types/express": "^5.0.3", + "@types/node": "^22.13.1", + "express": "^5.1.0", + "tsdown": "^0.15.6", + "typescript": "~5.7.3", + "vitest": "5.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/godaddy/javascript.git", + "directory": "packages/commerce-server" + } +} diff --git a/packages/commerce-server/src/checkout-route.test.ts b/packages/commerce-server/src/checkout-route.test.ts new file mode 100644 index 00000000..5da3dcc3 --- /dev/null +++ b/packages/commerce-server/src/checkout-route.test.ts @@ -0,0 +1,145 @@ +import { once } from 'node:events'; +import type { Server } from 'node:http'; +import express from 'express'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRuntimeCommerceConfiguration } from './lib/commerce/config'; +import { createCheckoutSession } from './lib/commerce/create-checkout-session'; +import { createCommerceRouter, createGoDaddyPaymentsRouter } from './router'; + +vi.mock('./lib/commerce/create-checkout-session', () => ({ createCheckoutSession: vi.fn() })); +const policy = { + returnUrls: ['https://shop.example.com/shop'], + successUrls: [ + 'https://shop.example.com/checkout/success', + 'https://shop.example.com/receipt?campaign=spring', + ], +}; +const valid = { + draftOrderId: 'cart-1', + returnUrl: policy.returnUrls[0], + successUrl: `${policy.successUrls[0]}?orderId=cart-1`, +}; +let server: Server; +let base: string; + +beforeAll(async (): Promise => { + const configuration = createRuntimeCommerceConfiguration({ environment: {} }); + const app = express(); + app.use(express.json()); + app.use('/configured', createCommerceRouter({ configuration, checkoutReturnUrls: policy })); + app.use('/missing', createCommerceRouter({ configuration })); + app.use('/payments', createGoDaddyPaymentsRouter(configuration, policy)); + server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); + base = `http://127.0.0.1:${address.port}`; +}); +afterAll(async (): Promise => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); +beforeEach((): void => { + vi.clearAllMocks(); + vi.mocked(createCheckoutSession).mockResolvedValue({ + id: 'session-1', + url: 'https://checkout.example.com/session-1', + draftOrderId: 'cart-1', + storeId: 'store-1', + channelId: 'channel-1', + businessId: null, + storeName: null, + sourceApp: null, + }); +}); + +function post(body: unknown, route = '/configured'): Promise { + return fetch(`${base}${route}/checkout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://attacker.example.com', + 'X-Forwarded-Host': 'attacker.example.com', + }, + body: JSON.stringify(body), + }); +} + +describe('public checkout boundary', () => { + it.each(['/configured', '/payments'])( + 'allows catalog checkout with a configured destination through %s', + async (route): Promise => { + const response = await post(valid, route); + expect(response.status).toBe(200); + expect(createCheckoutSession).toHaveBeenCalledWith(expect.objectContaining(valid), expect.anything()); + }, + ); + + it('allows SKU checkout and preserves configured query parameters', async (): Promise => { + const body = { + skuId: 'sku-1', + quantity: 2, + returnUrl: valid.returnUrl, + successUrl: 'https://shop.example.com/receipt?orderId=cart-1&campaign=spring', + }; + expect((await post(body)).status).toBe(200); + expect(createCheckoutSession).toHaveBeenCalledWith(expect.objectContaining(body), expect.anything()); + }); + + it.each([false, true])( + 'rejects browser-supplied non-catalog prices (with catalog source: %s)', + async (includeCart): Promise => { + const response = await post({ + ...valid, + draftOrderId: includeCart ? 'cart-1' : undefined, + lineItemData: { name: 'Fixed deposit', priceData: { unitAmount: 1, currencyCode: 'USD' } }, + }); + expect(response.status).toBe(400); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }, + ); + + it('fails closed when the host has not configured return destinations', async (): Promise => { + expect((await post(valid, '/missing')).status).toBe(503); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['returnUrl', 'https://attacker.example.com/shop'], + ['successUrl', 'https://attacker.example.com/checkout/success'], + ['successUrl', 'https://shop.example.com.attacker.example.com/checkout/success'], + ['successUrl', 'https://shop.example.com@attacker.example.com/checkout/success'], + ['successUrl', 'https://user:password@shop.example.com/checkout/success'], + ['successUrl', 'https://shop.example.com/checkout/success/extra'], + ['successUrl', 'https://shop.example.com/redirect?next=https://attacker.example.com'], + ['successUrl', 'https://shop.example.com/checkout/success?next=https://attacker.example.com'], + ['successUrl', 'https://shop.example.com/checkout/success?orderId=one&orderId=two'], + ['successUrl', 'https://shop.example.com/receipt?campaign=other'], + ['successUrl', 'https://shop.example.com/checkout/success#https://attacker.example.com'], + ['successUrl', 'https://shop.example.com:8443/checkout/success'], + ['successUrl', 'https://shop.example.com/shop'], + ['returnUrl', 'https://shop.example.com/checkout/success'], + ['successUrl', '//shop.example.com/checkout/success'], + ['successUrl', '/checkout/success'], + ['successUrl', 'http://shop.example.com/checkout/success'], + ['successUrl', 'javascript:alert(1)'], + ['successUrl', 'https://shop.example.com/checkout/success\n'], + ['successUrl', 'https://shop.example.com\\@attacker.example.com/checkout/success'], + ['successUrl', {}], + ['successUrl', null], + ['successUrl', ['https://shop.example.com/checkout/success']], + ])('rejects unapproved %s: %j before creating checkout', async (field, value): Promise => { + expect((await post({ ...valid, [field]: value })).status).toBe(400); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }); + + it.each([ + 'http://shop.example.com/shop', + '//shop.example.com/shop', + 'https://user@shop.example.com/shop', + 'https://shop.example.com/shop#fragment', + ])('rejects invalid host destination configuration %s at mount time', (url): void => { + expect(() => + createCommerceRouter({ checkoutReturnUrls: { returnUrls: [url], successUrls: [] } }), + ).toThrow('Checkout return destinations'); + }); +}); diff --git a/packages/commerce-server/src/config.test.ts b/packages/commerce-server/src/config.test.ts new file mode 100644 index 00000000..17b5db12 --- /dev/null +++ b/packages/commerce-server/src/config.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRuntimeCommerceConfiguration, readCommerceConfig } from './lib/commerce/config'; + +function environment(): NodeJS.ProcessEnv { + return { + GODADDY_OAUTH_CLIENT_ID: 'client-1', + GODADDY_OAUTH_CLIENT_SECRET: 'secret-1', + GODADDY_STORE_ID: 'store-1', + GODADDY_CHANNEL_ID: 'channel-1', + GODADDY_CURRENCY_CODE: 'USD', + }; +} + +afterEach((): void => { + vi.unstubAllEnvs(); +}); + +describe('Commerce runtime configuration', () => { + it('defaults to production and does not select an API origin from environment variables', (): void => { + const config = readCommerceConfig({ + environment: { ...environment(), GODADDY_API_BASE_URL: 'https://api.example.com' }, + }); + expect(config.apiBaseUrl).toBe('https://api.godaddy.com'); + expect(config.clientSecret).toBe('secret-1'); + expect(config).not.toHaveProperty('sourceApp'); + expect(config).not.toHaveProperty('owner'); + }); + + it('reads process.env when no environment is supplied', (): void => { + for (const [key, value] of Object.entries(environment())) vi.stubEnv(key, value); + expect(createRuntimeCommerceConfiguration().read()).toMatchObject({ + storeId: 'store-1', + apiBaseUrl: 'https://api.godaddy.com', + }); + }); + + it('rereads host environment values in the same process', (): void => { + const values = environment(); + const configuration = createRuntimeCommerceConfiguration({ environment: values }); + expect(configuration.read().storeId).toBe('store-1'); + values.GODADDY_STORE_ID = 'store-2'; + expect(configuration.read().storeId).toBe('store-2'); + }); + + it('accepts an explicit host-owned API origin and attribution', (): void => { + const configuration = createRuntimeCommerceConfiguration({ + environment: environment(), + apiBaseUrl: 'https://api.example.com/', + sourceApp: 'merchant-site', + owner: 'merchant-orders', + }); + expect(configuration.read()).toMatchObject({ + apiBaseUrl: 'https://api.example.com', + sourceApp: 'merchant-site', + owner: 'merchant-orders', + }); + }); + + it.each([ + 'not a URL', + 'http://api.example.com', + 'https://user:secret@api.example.com', + 'https://api.example.com/path', + 'https://api.example.com?key=value', + 'https://api.example.com#fragment', + ])('rejects an invalid API origin: %s', (apiBaseUrl): void => { + expect(() => readCommerceConfig({ environment: environment(), apiBaseUrl })).toThrow( + 'apiBaseUrl must be', + ); + }); + + it.each(Object.keys(environment()))('requires the server configuration value %s', (key): void => { + const values = environment(); + delete values[key]; + expect(() => readCommerceConfig({ environment: values })).toThrow(`${key} is missing`); + }); + + it('reads checkout flags and API shipping options', (): void => { + const values = environment(); + const configuration = createRuntimeCommerceConfiguration({ environment: values }); + expect(configuration.readCheckout()).toEqual({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + }); + values.GODADDY_CHECKOUT_CONFIGURATION = JSON.stringify({ + enablePromotionCodes: true, + enableTaxCollection: false, + enableShipping: true, + shipping: { fulfillmentLocationId: 'location-1' }, + }); + expect(configuration.readCheckout()).toEqual({ + enablePromotionCodes: true, + enableTaxCollection: false, + enableShipping: true, + shipping: { fulfillmentLocationId: 'location-1' }, + }); + }); + + it.each(['{bad json', 'null', '{"enableShipping":true}'])( + 'rejects malformed checkout configuration: %s', + (raw): void => { + expect(() => + createRuntimeCommerceConfiguration({ + environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, + }).readCheckout(), + ).toThrow('Commerce config: GODADDY_CHECKOUT_CONFIGURATION'); + }, + ); +}); diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts new file mode 100644 index 00000000..ff9ec77e --- /dev/null +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -0,0 +1,190 @@ +import { once } from 'node:events'; +import express from 'express'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createRuntimeCommerceConfiguration } from './lib/commerce/config'; +import { createCommerceRouter } from './router'; + +const clientFetch = globalThis.fetch; +afterEach((): void => { + vi.unstubAllGlobals(); +}); + +it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( + 'uses host configuration throughout the router with API override %s', + async (apiBaseUrl): Promise => { + const origin = apiBaseUrl ?? 'https://api.godaddy.com'; + const owner = apiBaseUrl ? 'merchant-orders' : undefined; + const sourceApp = apiBaseUrl ? 'merchant-site' : undefined; + const upstream = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url.endsWith('/v2/oauth2/token')) { + expect(new URLSearchParams(String(init?.body)).get('client_secret')).toBe('secret-1'); + return Response.json({ access_token: 'token', expires_in: 3600 }); + } + if (url.includes('/orders/')) { + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer token'); + return Response.json({ + order: { + id: 'cart-1', + context: { storeId: 'store-1', channelId: 'channel-1' }, + statuses: { paymentStatus: 'PAID' }, + }, + }); + } + const body = JSON.parse(String(init?.body)); + if (body.query.includes('AddCartOrder')) { + expect(body.variables.input.context).toEqual({ + storeId: 'store-1', + channelId: 'channel-1', + ...(owner ? { owner } : {}), + }); + return Response.json({ data: { addDraftOrder: { id: 'cart-1' } } }); + } + if (body.query.includes('CreateCheckoutSession')) { + expect(body.variables.input.sourceApp).toBe(sourceApp); + expect(body.variables.input.owner).toBe(owner); + return Response.json({ + data: { + createCheckoutSession: { + id: 'session-1', + url: 'https://checkout.example.com/session-1', + storeId: 'store-1', + channelId: 'channel-1', + paymentMethods: { card: { processor: 'godaddy' } }, + }, + }, + }); + } + if (url.includes('catalog-subgraph')) return Response.json({ data: { skuGroups: { edges: [] } } }); + return Response.json({ data: { orderById: { id: 'cart-1' } } }); + }); + vi.stubGlobal('fetch', upstream); + const configuration = createRuntimeCommerceConfiguration({ + apiBaseUrl, + owner, + sourceApp, + environment: { + GODADDY_OAUTH_CLIENT_ID: 'client-1', + GODADDY_OAUTH_CLIENT_SECRET: 'secret-1', + GODADDY_STORE_ID: 'store-1', + GODADDY_CHANNEL_ID: 'channel-1', + GODADDY_CURRENCY_CODE: 'USD', + }, + }); + const app = express(); + app.use(express.json()); + app.use( + '/api/commerce', + createCommerceRouter({ + configuration, + checkoutReturnUrls: { + returnUrls: ['https://example.com/cart'], + successUrls: ['https://example.com/success'], + }, + }), + ); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); + const base = `http://127.0.0.1:${address.port}/api/commerce`; + const configResponse = await clientFetch(`${base}/config`); + const publicConfig = await configResponse.json(); + expect(publicConfig).toEqual({ cartScope: expect.any(String), currencyCode: 'USD' }); + const headers = { 'Content-Type': 'application/json', 'X-Commerce-Scope': publicConfig.cartScope }; + expect((await clientFetch(`${base}/products`, { headers })).status).toBe(200); + expect( + ( + await clientFetch(`${base}/cart`, { + method: 'POST', + headers, + body: JSON.stringify({ owner: 'untrusted-owner' }), + }) + ).status, + ).toBe(201); + expect( + ( + await clientFetch(`${base}/checkout`, { + method: 'POST', + headers, + body: JSON.stringify({ + draftOrderId: 'cart-1', + returnUrl: 'https://example.com/cart', + successUrl: 'https://example.com/success', + apiBaseUrl: 'https://untrusted.example.com', + sourceApp: 'untrusted-source', + owner: 'untrusted-owner', + }), + }) + ).status, + ).toBe(200); + expect((await clientFetch(`${base}/order-status?orderId=cart-1`, { headers })).status).toBe(200); + expect(upstream.mock.calls.map(([url]) => String(url))).toEqual([ + `${origin}/v2/commerce/stores/store-1/catalog-subgraph/storefront`, + `${origin}/v1/commerce/order-storefront-subgraph`, + `${origin}/v1/commerce/order-storefront-subgraph`, + `${origin}/v2/oauth2/token`, + `https://checkout.commerce.${new URL(origin).host}`, + `${origin}/v2/oauth2/token`, + `${origin}/v1/commerce/stores/store-1/orders/cart-1`, + ]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, +); + +it.each([ + ['Order not found', 200, { cart: null }], + [ + 'Authentication token expired', + 500, + { error: 'Failed to load cart', message: 'Authentication token expired' }, + ], + ['Database unavailable', 500, { error: 'Failed to load cart', message: 'Database unavailable' }], +] as const)( + 'handles the actual Apollo error envelope for %s', + async (message, status, body): Promise => { + vi.stubGlobal( + 'fetch', + vi.fn( + async (): Promise => + Response.json({ + data: { orderById: null }, + errors: [{ message, extensions: { code: 'INTERNAL_SERVER_ERROR' } }], + }), + ), + ); + const app = express(); + app.use( + '/api/commerce', + createCommerceRouter({ + configuration: createRuntimeCommerceConfiguration({ + environment: { + GODADDY_OAUTH_CLIENT_ID: 'client-1', + GODADDY_OAUTH_CLIENT_SECRET: 'secret-1', + GODADDY_STORE_ID: 'store-1', + GODADDY_CHANNEL_ID: 'channel-1', + GODADDY_CURRENCY_CODE: 'USD', + }, + }), + }), + ); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); + const response = await clientFetch(`http://127.0.0.1:${address.port}/api/commerce/cart/completed-cart`); + expect(response.status).toBe(status); + expect(await response.json()).toEqual(body); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, +); diff --git a/packages/commerce-server/src/create-checkout-session.test.ts b/packages/commerce-server/src/create-checkout-session.test.ts new file mode 100644 index 00000000..82cb754a --- /dev/null +++ b/packages/commerce-server/src/create-checkout-session.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CommerceCheckoutConfiguration } from './lib/commerce/checkout-config'; +import type { CheckoutSessionResult, CreateCheckoutSessionResult } from './lib/commerce/checkout-subgraph'; +import type { CommerceConfig, CommerceConfiguration } from './lib/commerce/config'; +import { + type CreateCheckoutSessionParams, + createCheckoutSession, +} from './lib/commerce/create-checkout-session'; + +const { mockGetOAuthAccessToken, mockGqlRequest } = vi.hoisted(() => ({ + mockGetOAuthAccessToken: vi.fn(), + mockGqlRequest: vi.fn(), +})); +vi.mock('./lib/commerce/checkout-subgraph', async (importOriginal) => ({ + ...(await importOriginal()), + getOAuthAccessToken: mockGetOAuthAccessToken, +})); +vi.mock('./lib/commerce/gql', () => ({ gqlRequest: mockGqlRequest })); + +const urls = { returnUrl: 'https://example.com/cart', successUrl: 'https://example.com/success' }; +const flows = [ + ['non-catalog', { ...urls, lineItemData: { name: 'Camp registration', priceData: { unitAmount: 59900 } } }], + ['cart', { ...urls, draftOrderId: 'draft-1' }], + ['buy-now', { ...urls, skuId: 'sku-1' }], +] as const; +const nonCatalog = flows[0][1]; +const cart = flows[1][1]; +let config: CommerceConfig; +let checkout: CommerceCheckoutConfiguration; +const configuration: CommerceConfiguration = { + read: vi.fn(() => config), + readCheckout: () => checkout, +}; + +function response(overrides: Partial = {}): CreateCheckoutSessionResult { + return { + createCheckoutSession: { + id: 'session-1', + url: 'https://checkout.commerce.godaddy.com/c/session-1', + storeId: 'store-1', + businessId: 'business-1', + channelId: 'channel-1', + storeName: 'Future Makers Camp', + paymentMethods: { card: { processor: 'godaddy', checkoutTypes: ['standard'] } }, + ...overrides, + }, + }; +} + +beforeEach((): void => { + vi.resetAllMocks(); + config = { + clientId: 'client-1', + clientSecret: 'secret-1', + storeId: 'store-1', + channelId: 'channel-1', + apiBaseUrl: 'https://api.godaddy.com', + currencyCode: 'USD', + }; + checkout = { enablePromotionCodes: false, enableTaxCollection: false, enableShipping: false }; + vi.mocked(configuration.read).mockImplementation(() => config); + mockGetOAuthAccessToken.mockResolvedValue({ + access_token: 'access-token', + scope: 'commerce.product:read', + expires_in: 3600, + }); + mockGqlRequest.mockResolvedValue(response()); +}); + +describe('createCheckoutSession', () => { + it('returns verified checkout data without assigning host attribution', async (): Promise => { + await expect(createCheckoutSession(nonCatalog, configuration)).resolves.toEqual({ + url: 'https://checkout.commerce.godaddy.com/c/session-1', + id: 'session-1', + draftOrderId: null, + storeId: 'store-1', + channelId: 'channel-1', + businessId: 'business-1', + storeName: 'Future Makers Camp', + sourceApp: null, + }); + expect(configuration.read).toHaveBeenCalledTimes(1); + expect(mockGetOAuthAccessToken).toHaveBeenCalledWith({ + clientId: 'client-1', + clientSecret: 'secret-1', + apiBaseUrl: 'https://api.godaddy.com', + scope: 'commerce.product:read', + }); + }); + + it.each(flows)( + 'uses default payments without built-in attribution for %s checkout', + async (_name, params): Promise => { + await createCheckoutSession(params, configuration); + const input = mockGqlRequest.mock.calls[0]?.[0].variables.input; + expect(input).toMatchObject({ + paymentMethods: { card: { processor: 'godaddy', checkoutTypes: ['standard'] } }, + }); + expect(input).not.toHaveProperty('sourceApp'); + expect(input).not.toHaveProperty('owner'); + }, + ); + + it.each(flows)('uses only host-owned attribution for %s checkout', async (_name, params): Promise => { + config = { ...config, sourceApp: 'merchant-site', owner: 'merchant-orders' }; + mockGqlRequest.mockResolvedValue(response({ sourceApp: 'merchant-site' })); + const input = { + ...params, + sourceApp: 'untrusted-source', + owner: 'untrusted-owner', + apiBaseUrl: 'https://untrusted.example.com', + }; + await expect(createCheckoutSession(input, configuration)).resolves.toMatchObject({ + sourceApp: 'merchant-site', + }); + expect(mockGqlRequest.mock.calls[0]?.[0].variables.input).toMatchObject({ + sourceApp: 'merchant-site', + owner: 'merchant-orders', + }); + expect(mockGetOAuthAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ apiBaseUrl: 'https://api.godaddy.com' }), + ); + }); + + it('uses the host API origin for OAuth and hosted checkout', async (): Promise => { + config.apiBaseUrl = 'https://api.example.com'; + await createCheckoutSession(cart, configuration); + expect(mockGetOAuthAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ apiBaseUrl: 'https://api.example.com' }), + ); + expect(mockGqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'https://checkout.commerce.api.example.com' }), + ); + }); + + it.each(['storeId', 'channelId'] as const)( + 'rejects a checkout session with a different %s', + async (field): Promise => { + mockGqlRequest.mockResolvedValue(response({ [field]: 'other' })); + await expect(createCheckoutSession(cart, configuration)).rejects.toThrow( + 'Checkout session binding mismatch', + ); + }, + ); + + it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise => { + mockGqlRequest.mockResolvedValue(response({ paymentMethods: null })); + await expect(createCheckoutSession(params, configuration)).rejects.toThrow( + 'Checkout session did not configure payment methods.', + ); + }); + + it.each([flows[1], flows[2]])( + 'uses the store shipping configuration for %s checkout', + async (_name, params): Promise => { + checkout = { enablePromotionCodes: true, enableTaxCollection: true, enableShipping: true }; + mockGqlRequest.mockResolvedValue( + response({ + enablePromotionCodes: true, + enableTaxCollection: true, + enableShipping: true, + enableShippingAddressCollection: true, + }), + ); + await createCheckoutSession(params, configuration); + const input = mockGqlRequest.mock.calls[0]?.[0].variables.input; + expect(input).toMatchObject({ + enablePromotionCodes: true, + enableTaxCollection: true, + enableShipping: true, + enableShippingAddressCollection: true, + }); + expect(input).not.toHaveProperty('shipping'); + }, + ); + + it.each([ + { + originAddress: { + addressLine1: '123 Main St', + adminArea1: 'AZ', + adminArea2: 'Tempe', + postalCode: '85281', + countryCode: 'US', + }, + }, + { fulfillmentLocationId: 'location-1' }, + ])('passes explicit API shipping options from the host: %j', async (shipping): Promise => { + checkout = { ...checkout, enableShipping: true, shipping }; + mockGqlRequest.mockResolvedValue( + response({ enableShipping: true, enableShippingAddressCollection: true }), + ); + await createCheckoutSession(cart, configuration); + expect(mockGqlRequest.mock.calls[0]?.[0].variables.input.shipping).toEqual(shipping); + }); + + it('does not apply shipping or promotion codes to non-catalog checkout', async (): Promise => { + checkout = { + enablePromotionCodes: true, + enableTaxCollection: true, + enableShipping: true, + shipping: { fulfillmentLocationId: 'location-1' }, + }; + mockGqlRequest.mockResolvedValue(response({ enableTaxCollection: true })); + await createCheckoutSession(nonCatalog, configuration); + const input = mockGqlRequest.mock.calls[0]?.[0].variables.input; + expect(input).toMatchObject({ + enableTaxCollection: true, + enableShipping: false, + enableShippingAddressCollection: false, + }); + expect(input).not.toHaveProperty('enablePromotionCodes'); + expect(input).not.toHaveProperty('shipping'); + }); + + it.each([ + 'enablePromotionCodes', + 'enableTaxCollection', + 'enableShipping', + 'enableShippingAddressCollection', + ] as const)('rejects sessions that omit configured %s', async (field): Promise => { + checkout = { enablePromotionCodes: true, enableTaxCollection: true, enableShipping: true }; + mockGqlRequest.mockResolvedValue( + response({ + enablePromotionCodes: true, + enableTaxCollection: true, + enableShipping: true, + enableShippingAddressCollection: true, + [field]: false, + }), + ); + await expect(createCheckoutSession(cart, configuration)).rejects.toThrow( + 'Checkout session did not enable configured', + ); + }); + + it('uses the configured currency for non-catalog pricing', async (): Promise => { + config.currencyCode = 'GBP'; + await createCheckoutSession( + { ...nonCatalog, lineItemData: { name: 'Camp', priceData: { unitAmount: 500, currencyCode: 'USD' } } }, + configuration, + ); + expect(mockGqlRequest.mock.calls[0]?.[0].variables.input.lineItems).toEqual([ + { quantity: 1, lineItemData: { name: 'Camp', priceData: { unitAmount: 500, currencyCode: 'GBP' } } }, + ]); + }); + + it('returns host configuration errors immediately without OAuth or checkout requests', async (): Promise => { + vi.mocked(configuration.read).mockImplementation(() => { + throw new Error('Host configuration unavailable'); + }); + await expect(createCheckoutSession(cart, configuration)).rejects.toThrow( + 'Host configuration unavailable', + ); + expect(configuration.read).toHaveBeenCalledTimes(1); + expect(mockGetOAuthAccessToken).not.toHaveBeenCalled(); + expect(mockGqlRequest).not.toHaveBeenCalled(); + }); + + it('redacts upstream checkout details while retaining the cause', async (): Promise => { + const upstream = new Error('Invalid origin address: 123 Main Street'); + mockGqlRequest.mockRejectedValue(upstream); + const error = await createCheckoutSession(cart, configuration).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Commerce checkout session could not be created'); + expect((error as Error).cause).toBe(upstream); + }); + + it.each([{ ...urls }, { ...cart, skuId: 'sku-1' }, { ...nonCatalog, draftOrderId: 'draft-1' }])( + 'rejects ambiguous or missing checkout sources before accessing credentials', + async (params): Promise => { + await expect( + createCheckoutSession(params as CreateCheckoutSessionParams, configuration), + ).rejects.toThrow('exactly one of'); + expect(configuration.read).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/commerce-server/src/get-order-status.test.ts b/packages/commerce-server/src/get-order-status.test.ts new file mode 100644 index 00000000..ca89fc3f --- /dev/null +++ b/packages/commerce-server/src/get-order-status.test.ts @@ -0,0 +1,167 @@ +import { once } from 'node:events'; +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRuntimeCommerceConfiguration } from './lib/commerce/config'; +import { getOrderStatus } from './lib/commerce/get-order-status'; +import { createGoDaddyPaymentsRouter } from './router'; + +const clientFetch = globalThis.fetch; +const environment = { + GODADDY_OAUTH_CLIENT_ID: 'client-1', + GODADDY_OAUTH_CLIENT_SECRET: 'secret-1', + GODADDY_STORE_ID: 'store-1', + GODADDY_CHANNEL_ID: 'channel-1', + GODADDY_CURRENCY_CODE: 'GBP', +}; +const configuration = createRuntimeCommerceConfiguration({ environment }); +const order = { + id: 'completed-order', + context: { storeId: 'store-1', channelId: 'channel-1' }, + statuses: { status: 'COMPLETED', paymentStatus: 'PAID', fulfillmentStatus: 'FULFILLED' }, + totals: { total: { value: 2500, currencyCode: 'GBP' } }, + createdAt: '2026-09-22T10:00:00Z', + updatedAt: '2026-09-22T10:05:00Z', + lineItems: [{ id: 'line-1', title: 'Mug', quantity: 1, notes: ['Private note'] }], + billing: { email: 'private@example.com' }, + customerId: 'private-customer', +}; +const summary = { + id: 'completed-order', + status: 'PAID', + amount: 2500, + currency: 'GBP', + createdAt: order.createdAt, + updatedAt: order.updatedAt, + lineItems: [{ id: 'line-1', name: 'Mug', quantity: 1 }], +}; +let upstream: ReturnType>; + +beforeEach((): void => { + upstream = vi.fn(async (input): Promise => { + const url = String(input); + if (url.endsWith('/v2/oauth2/token')) + return Response.json({ access_token: 'order-token', expires_in: 3600 }); + if (url.includes('order-storefront-subgraph')) + return Response.json({ + data: { orderById: null }, + errors: [{ message: 'Order not found', extensions: { code: 'INTERNAL_SERVER_ERROR' } }], + }); + return Response.json({ order }); + }); + vi.stubGlobal('fetch', upstream); +}); +afterEach((): void => { + vi.unstubAllGlobals(); +}); + +describe('authorized order lookup', () => { + it('loads a completed order using an order-read token and the store-scoped REST endpoint', async (): Promise => { + await expect(getOrderStatus(order.id, configuration)).resolves.toEqual(summary); + expect(upstream).toHaveBeenCalledTimes(2); + const [tokenUrl, tokenInit] = upstream.mock.calls[0] ?? []; + expect(String(tokenUrl)).toBe('https://api.godaddy.com/v2/oauth2/token'); + const grant = new URLSearchParams(String(tokenInit?.body)); + expect(grant.get('scope')).toBe('commerce.order:read'); + expect(grant.get('client_secret')).toBe('secret-1'); + const [orderUrl, orderInit] = upstream.mock.calls[1] ?? []; + expect(String(orderUrl)).toBe( + 'https://api.godaddy.com/v1/commerce/stores/store-1/orders/completed-order', + ); + expect(orderInit).toMatchObject({ method: 'GET', cache: 'no-store' }); + expect(new Headers(orderInit?.headers).get('Authorization')).toBe('Bearer order-token'); + expect(new Headers(orderInit?.headers).has('X-Client-ID')).toBe(false); + }); + + it('returns HTTP 200 after payment even though the storefront lookup rejects that order', async (): Promise => { + const app = express(); + app.use('/api/commerce', createGoDaddyPaymentsRouter(configuration)); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); + const response = await clientFetch( + `http://127.0.0.1:${address.port}/api/commerce/order-status?orderId=${order.id}`, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, order: summary }); + expect(upstream.mock.calls.some(([url]) => String(url).includes('storefront'))).toBe(false); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + + it('uses the host API origin and encodes store and order path segments', async (): Promise => { + const storeId = 'store/one'; + const orderId = 'order/with?reserved#characters'; + const config = createRuntimeCommerceConfiguration({ + apiBaseUrl: 'https://api.example.com', + environment: { ...environment, GODADDY_STORE_ID: storeId }, + }); + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce( + Response.json({ order: { ...order, id: orderId, context: { ...order.context, storeId } } }), + ); + await expect(getOrderStatus(orderId, config)).resolves.toMatchObject({ id: orderId }); + expect(upstream.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://api.example.com/v2/oauth2/token', + 'https://api.example.com/v1/commerce/stores/store%2Fone/orders/order%2Fwith%3Freserved%23characters', + ]); + }); + + it.each(['PENDING', 'AUTHORIZED', 'PAID', 'REFUNDED', undefined])( + 'uses payment status %s rather than inferring it from order completion', + async (paymentStatus): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce( + Response.json({ order: { ...order, statuses: { status: 'COMPLETED', paymentStatus } } }), + ); + await expect(getOrderStatus(order.id, configuration)).resolves.toMatchObject({ + status: paymentStatus ?? 'unknown', + }); + }, + ); + + it.each([ + undefined, + { ...order, id: 'another-order' }, + { ...order, context: { ...order.context, storeId: 'another-store' } }, + { ...order, context: { ...order.context, channelId: 'another-channel' } }, + { ...order, context: undefined }, + ])('rejects missing or mismatched order bindings', async (result): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(Response.json({ order: result })); + await expect(getOrderStatus(order.id, configuration)).rejects.toThrow('Order lookup'); + }); + + it.each([401, 403, 404, 500])( + 'preserves an upstream order lookup failure (%i) without exposing its body', + async (status): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(new Response('Private upstream details', { status })); + await expect(getOrderStatus(order.id, configuration)).rejects.toThrow( + `Failed to load order: upstream returned ${status}`, + ); + }, + ); + + it('does not query orders when the order-read scope is denied', async (): Promise => { + upstream.mockResolvedValueOnce(new Response('Invalid scope', { status: 403 })); + await expect(getOrderStatus(order.id, configuration)).rejects.toThrow('Failed to get access token: 403'); + expect(upstream).toHaveBeenCalledTimes(1); + }); + + it.each(['', ' ', '.', '..'])( + 'rejects invalid order ID %j before requesting credentials', + async (id): Promise => { + await expect(getOrderStatus(id, configuration)).rejects.toThrow('a valid orderId is required'); + expect(upstream).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts new file mode 100644 index 00000000..fd69b46b --- /dev/null +++ b/packages/commerce-server/src/index.ts @@ -0,0 +1,29 @@ +export { + type CommerceCheckoutConfiguration, + type CommerceCheckoutShippingConfiguration, + parseCommerceCheckoutConfiguration, +} from './lib/commerce/checkout-config'; +export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls'; +export { + type CommerceConfig, + type CommerceConfiguration, + createRuntimeCommerceConfiguration, + type RuntimeCommerceConfigurationOptions, + readCommerceConfig, +} from './lib/commerce/config'; +export { + type CheckoutSession, + type CreateCheckoutSessionParams, + createCheckoutSession, +} from './lib/commerce/create-checkout-session'; +export { + type CommerceOrderStatus, + getOrderStatus, +} from './lib/commerce/get-order-status'; +export { + type CommerceRouterFeatures, + type CreateCommerceRouterOptions, + createCommerceCatalogRouter, + createCommerceRouter, + createGoDaddyPaymentsRouter, +} from './router'; diff --git a/packages/commerce-server/src/lib/commerce/cart-scope.ts b/packages/commerce-server/src/lib/commerce/cart-scope.ts new file mode 100644 index 00000000..8854944a --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/cart-scope.ts @@ -0,0 +1,22 @@ +// SERVER ONLY: uses node:crypto. Browser code receives cartScope from GET /api/commerce/config. +import { createHash } from 'node:crypto'; +import type { Request, Response } from 'express'; +import type { CommerceConfig } from './config'; + +type CartBinding = Pick; + +/** Public cache/storage scope, not an authorization credential. */ +export function getCommerceCartScope(config: CartBinding): string { + return createHash('sha256') + .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId])) + .digest('hex') + .slice(0, 32); +} + +/** Existing custom clients may omit the header; managed components always send it. */ +export function validateCommerceCartScope(req: Request, res: Response, config: CartBinding): boolean { + const suppliedScope: string | string[] | undefined = req.headers?.['x-commerce-scope']; + if (suppliedScope === undefined || suppliedScope === getCommerceCartScope(config)) return true; + res.status(409).json({ error: 'The connected store changed. Reload the page before continuing.' }); + return false; +} diff --git a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts new file mode 100644 index 00000000..adeeb135 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts @@ -0,0 +1,310 @@ +/** + * GoDaddy Commerce catalog-subgraph kit. + * + * Server-side proxy routes under `src/server/api/commerce/products` and + * `src/server/api/commerce/skus` import this file to fetch SKUGroups and + * SKUs. The catalog endpoint is public-readable but the proxy still owns + * the `X-Store-ID` / `X-Client-ID` headers so the browser stays clean. + * + * The interfaces and view-model helpers (`getPrimaryImageUrl`, + * `getProductAttributes`, `getSingleMatchedSkuId`, + * `getAvailableInventoryQuantity`) are isomorphic and safe to import from + * client components when shaping props from API responses. + * + * Domain notes: + * - A storefront "product" is a SKUGroup; a purchasable variant is a SKU. + * - Filters: `id.in` for explicit ids, `listId.in` for category lists, + * `label.contains` for search. + * - SKU media/prices should override SKUGroup-level ones once a SKU is selected. + */ + +export interface CatalogStorefrontEndpointInput { + storeId: string; + apiBaseUrl: string; +} + +export interface PageInfo { + hasNextPage?: boolean | null; + hasPreviousPage?: boolean | null; + startCursor?: string | null; + endCursor?: string | null; +} + +export interface ConnectionEdge { + cursor?: string | null; + node?: T | null; +} + +export interface Connection { + edges?: Array | null> | null; + pageInfo?: PageInfo | null; + totalCount?: number | null; +} + +export interface PriceRange { + min?: number | null; + max?: number | null; +} + +export interface MediaObject { + id?: string | null; + url?: string | null; + type?: string | null; + label?: string | null; + position?: number | null; +} + +export interface InventoryCount { + id?: string | null; + quantity?: number | null; + type?: string | null; +} + +export interface SKUGroupAttributeValue { + id?: string | null; + name?: string | null; + label?: string | null; +} + +export interface SKUGroupAttribute { + id?: string | null; + name?: string | null; + label?: string | null; + description?: string | null; + htmlDescription?: string | null; + values?: Connection | null; +} + +export type SKUGroupSKU = SKU; + +export interface SKUGroup { + id?: string | null; + name?: string | null; + label?: string | null; + description?: string | null; + htmlDescription?: string | null; + type?: string | null; + priceRange?: PriceRange | null; + compareAtPriceRange?: PriceRange | null; + mediaObjects?: Connection | null; + attributes?: Connection | null; + skus?: Connection | null; +} + +import type { Money } from './gql'; + +export interface SKUPrice { + id?: string | null; + value?: Money | null; + compareAtValue?: Money | null; +} + +export interface SKUAttributeValue { + id?: string | null; + name?: string | null; + label?: string | null; +} + +export interface SKU { + id?: string | null; + label?: string | null; + name?: string | null; + description?: string | null; + htmlDescription?: string | null; + code?: string | null; + prices?: Connection | null; + inventoryCounts?: Connection | null; + mediaObjects?: Connection | null; + attributeValues?: Connection | null; +} + +export interface StringInFilter { + in: string[]; +} + +export interface LabelFilter { + contains: string; +} + +export interface SkuGroupsVariables { + first?: number | null; + after?: string | null; + id?: StringInFilter; + listId?: StringInFilter; + label?: LabelFilter; +} + +export interface SkuGroupVariables { + id: string; + first?: number | null; + /** Attribute value `name` fields — the catalog API's `has` filter matches by `name`, not `id`. */ + attributeValues?: string[]; +} + +export interface SkuVariables { + id: string; +} + +export interface SkuGroupsResult { + skuGroups?: Connection | null; +} + +export interface SkuGroupResult { + skuGroup?: SKUGroup | null; +} + +export interface SkuResult { + sku?: SKU | null; +} + +export interface ProductGridVariablesInput { + first?: number | null; + after?: string | null; + productIds?: readonly string[]; + categoryIds?: readonly string[]; + searchQuery?: string; +} + +export interface ProductDetailsVariablesInput { + productId: string; + /** Attribute value `name` fields, not `id`. */ + selectedAttributeValues?: readonly string[]; + /** Number of SKUs to request before attributes are selected. Defaults to 50. */ + skuGroupFirst?: number; +} + +export interface StorefrontProductAttributeValue { + id: string; + name: string; + label: string; +} + +export interface StorefrontProductAttribute { + id: string; + name: string; + label: string; + values: StorefrontProductAttributeValue[]; +} + +type InventoryCountContainer = { + inventoryCounts?: Connection | null; +}; + +type MediaObjectContainer = { + mediaObjects?: Connection | null; +}; + +export function catalogStorefrontEndpoint({ storeId, apiBaseUrl }: CatalogStorefrontEndpointInput): string { + return new URL(`/v2/commerce/stores/${storeId}/catalog-subgraph/storefront`, apiBaseUrl).toString(); +} + +export function buildSkuGroupsVariables(input: ProductGridVariablesInput): SkuGroupsVariables { + const productIds = input.productIds?.filter(Boolean) ?? []; + const categoryIds = input.categoryIds?.filter(Boolean) ?? []; + const hasExplicitFilters = productIds.length > 0 || categoryIds.length > 0; + + return { + ...(input.first !== undefined && { first: input.first }), + ...(input.after && { after: input.after }), + ...(productIds.length > 0 && { id: { in: [...productIds] } }), + ...(categoryIds.length > 0 && { listId: { in: [...categoryIds] } }), + ...(!hasExplicitFilters && input.searchQuery && { label: { contains: input.searchQuery } }), + }; +} + +export function buildSkuGroupVariables({ + productId, + selectedAttributeValues, + skuGroupFirst, +}: ProductDetailsVariablesInput): SkuGroupVariables { + const attributeValues = selectedAttributeValues ? [...selectedAttributeValues] : []; + + return { + id: productId, + attributeValues, + ...(!attributeValues.length && { first: skuGroupFirst ?? 50 }), + }; +} + +export function getSingleMatchedSkuId(skuGroup: SKUGroup | null | undefined): string | null { + return getSingleMatchedSku(skuGroup)?.id ?? null; +} + +export function getSingleMatchedSku(skuGroup: SKUGroup | null | undefined): SKU | null { + const connection: Connection | null | undefined = skuGroup?.skus; + if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null; + const edges: Array | null> = connection?.edges ?? []; + const sku: SKU | null | undefined = edges.length === 1 ? edges[0]?.node : null; + return sku?.id ? sku : null; +} + +export function getLabeledSkuOptions(skuGroup: SKUGroup | null | undefined): SKU[] { + if (getProductAttributes(skuGroup).length > 0) return []; + const connection: Connection | null | undefined = skuGroup?.skus; + const edges: Array | null> = connection?.edges ?? []; + if ( + edges.length < 2 || + connection?.pageInfo?.hasNextPage || + (connection?.totalCount ?? edges.length) !== edges.length + ) + return []; + const skus: SKU[] = edges.flatMap((edge: ConnectionEdge | null): SKU[] => + edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : [], + ); + const labels: Set = new Set( + skus.map((sku: SKU): string => (sku.label?.trim() || sku.name?.trim() || '').toLowerCase()), + ); + const ids: Set = new Set( + skus.map((sku: SKU): string | null | undefined => sku.id), + ); + return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : []; +} + +export function getAvailableInventoryQuantity( + item: InventoryCountContainer | null | undefined, +): number | null { + const edges = item?.inventoryCounts?.edges; + if (!edges || edges.length === 0) { + // No inventory records = inventory not tracked (digital goods, services, etc.). + // Return null so callers can distinguish "unlimited" from "out of stock" (0). + return null; + } + return edges.find((edge) => edge?.node?.type === 'AVAILABLE')?.node?.quantity ?? 0; +} + +export function getImageUrls(item: MediaObjectContainer | null | undefined): string[] { + return ( + item?.mediaObjects?.edges + ?.filter((edge) => edge?.node?.type === 'IMAGE' && edge.node.url) + .map((edge) => edge?.node?.url) + .filter((url): url is string => Boolean(url)) ?? [] + ); +} + +export function getPrimaryImageUrl(item: MediaObjectContainer | null | undefined): string | null { + return getImageUrls(item)[0] ?? null; +} + +export function getProductAttributes(skuGroup: SKUGroup | null | undefined): StorefrontProductAttribute[] { + return ( + skuGroup?.attributes?.edges?.map((edge) => { + const attributeNode = edge?.node; + const values = + attributeNode?.values?.edges?.map((valueEdge) => { + const valueNode = valueEdge?.node; + return { + id: valueNode?.id || '', + name: valueNode?.name || '', + label: valueNode?.label || valueNode?.name || '', + }; + }) ?? []; + + return { + id: attributeNode?.id || '', + name: attributeNode?.name || '', + label: attributeNode?.label || attributeNode?.name || '', + values, + }; + }) ?? [] + ); +} diff --git a/packages/commerce-server/src/lib/commerce/checkout-config.ts b/packages/commerce-server/src/lib/commerce/checkout-config.ts new file mode 100644 index 00000000..7cae4047 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/checkout-config.ts @@ -0,0 +1,56 @@ +/** Shipping options accepted by the hosted checkout API. Omit to use the store's configuration. */ +export interface CommerceCheckoutShippingConfiguration { + readonly originAddress?: Readonly>; + readonly fulfillmentLocationId?: string; +} + +export interface CommerceCheckoutConfiguration { + readonly enablePromotionCodes: boolean; + readonly enableTaxCollection: boolean; + readonly enableShipping: boolean; + readonly shipping?: CommerceCheckoutShippingConfiguration; +} + +const DEFAULT_CHECKOUT_CONFIGURATION: CommerceCheckoutConfiguration = { + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, +}; + +export function parseCommerceCheckoutConfiguration(raw: string | undefined): CommerceCheckoutConfiguration { + if (!raw?.trim()) return DEFAULT_CHECKOUT_CONFIGURATION; + + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be valid JSON.', { + cause: error, + }); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be a JSON object.'); + } + + const candidate = value as Record; + for (const key of ['enablePromotionCodes', 'enableTaxCollection', 'enableShipping'] as const) { + if (typeof candidate[key] !== 'boolean') { + throw new Error(`Commerce config: GODADDY_CHECKOUT_CONFIGURATION.${key} must be boolean.`); + } + } + + const shipping = candidate.shipping; + if ( + shipping !== undefined && + (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) + ) { + throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION.shipping must be an object.'); + } + + return { + enablePromotionCodes: candidate.enablePromotionCodes as boolean, + enableTaxCollection: candidate.enableTaxCollection as boolean, + enableShipping: candidate.enableShipping as boolean, + ...(shipping ? { shipping: shipping as CommerceCheckoutShippingConfiguration } : {}), + }; +} diff --git a/packages/commerce-server/src/lib/commerce/checkout-return-urls.ts b/packages/commerce-server/src/lib/commerce/checkout-return-urls.ts new file mode 100644 index 00000000..0dbea868 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/checkout-return-urls.ts @@ -0,0 +1,55 @@ +/** Exact host-owned checkout destinations. Only success URLs may add an orderId query parameter. */ +export interface CheckoutReturnUrls { + returnUrls: readonly string[]; + successUrls: readonly string[]; +} + +export type CheckoutReturnUrlValidator = ( + returnUrl: unknown, + successUrl: unknown, +) => { returnUrl: string; successUrl: string } | null; + +function parseDestination(value: unknown): URL | null { + if (typeof value !== 'string' || /[\s\\]/.test(value)) return null; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) return null; + return url; + } catch { + return null; + } +} + +function destinationKey(url: URL): string { + const canonical = new URL(url); + canonical.searchParams.sort(); + return canonical.href; +} + +export function createCheckoutReturnUrlValidator(policy: CheckoutReturnUrls): CheckoutReturnUrlValidator { + function allowedDestinations(values: readonly string[]): Set { + return new Set( + values.map((value) => { + const url = parseDestination(value); + if (!url || url.searchParams.has('orderId')) { + throw new Error( + 'Checkout return destinations must be absolute HTTPS URLs without credentials, fragments, or orderId.', + ); + } + return destinationKey(url); + }), + ); + } + const returnUrls = allowedDestinations(policy.returnUrls); + const successUrls = allowedDestinations(policy.successUrls); + return (returnUrl, successUrl) => { + const cancel = parseDestination(returnUrl); + const success = parseDestination(successUrl); + if (!cancel || !success || success.searchParams.getAll('orderId').length > 1) return null; + const successDestination = new URL(success); + successDestination.searchParams.delete('orderId'); + if (!returnUrls.has(destinationKey(cancel)) || !successUrls.has(destinationKey(successDestination))) + return null; + return { returnUrl: cancel.href, successUrl: success.href }; + }; +} diff --git a/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts b/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts new file mode 100644 index 00000000..38056d6b --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts @@ -0,0 +1,430 @@ +/** + * GoDaddy Commerce hosted checkout GraphQL kit. + * + * Server-only. Loaded by `src/server/api/commerce/checkout/POST.ts` to mint an + * OAuth Bearer token (client_credentials grant) and create a hosted checkout + * session. Never import this file from the browser — `getOAuthAccessToken` would expose `clientSecret`. + * + * Common flows handled by the checkout proxy route: + * - Buy Now: lineItems: [{ skuId, quantity }] in the request body. + * - Cart checkout: existing draftOrderId in the request body. + * - Non-catalog: lineItems: [{ lineItemData: { name, priceData }, quantity }] — no SKU, no catalog product. + * - Existing custom cart: convert your cart into lineItems, then post to the route. + */ + +import type { Money } from './gql'; + +/** + * The hosted checkout page will not render a card form without this. GDC's + * checkout GraphQL accepts a session with no payment methods and returns 200, + * so an omitted or deleted default fails silently — the shopper sees "No + * payment methods available" with no error anywhere in the request/response + * cycle. Do not remove this while debugging an unrelated checkout issue. + */ +export const DEFAULT_CHECKOUT_PAYMENT_METHODS: CheckoutSessionPaymentMethodsInput = { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, +}; + +export interface OAuthTokenResponse { + access_token: string; + scope: string; + expires_in: number; + token_type?: string; +} + +export interface OAuthTokenInput { + clientId: string; + clientSecret: string; + apiBaseUrl: string; + /** Defaults to commerce.product:read to match current checkout integration. */ + scope?: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} + +export interface CheckoutEndpointInput { + apiBaseUrl: string; +} + +export interface CommerceApiEndpointInput { + apiBaseUrl: string; +} + +export interface AuthorizationHeadersInput { + accessToken: string; +} + +export interface CheckoutSessionLineItemPriceDataInput { + /** Price per unit in the currency's smallest unit (e.g. cents for USD). */ + unitAmount: number; + /** ISO 4217 currency code. If omitted, the server injects it from GODADDY_CURRENCY_CODE. */ + currencyCode?: string; +} + +export interface CheckoutSessionLineItemDataInput { + name: string; + priceData: CheckoutSessionLineItemPriceDataInput; +} + +/** + * Discriminated union: exactly one of skuId or lineItemData per line item. + * - Catalog flow: { skuId, quantity } + * - Non-catalog flow: { lineItemData, quantity } + * Providing both is a type error. + */ +export type CheckoutSessionLineItemInput = + | { skuId: string; quantity: number; lineItemData?: never } + | { lineItemData: CheckoutSessionLineItemDataInput; quantity: number; skuId?: never }; + +export interface CheckoutCustomerContactInput { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + companyName?: string; + address?: { + addressLine1?: string; + addressLine2?: string; + addressLine3?: string; + adminArea1?: string; + adminArea2?: string; + adminArea3?: string; + adminArea4?: string; + postalCode?: string; + countryCode?: string; + }; +} + +export interface CheckoutCustomerInput { + billing?: CheckoutCustomerContactInput; + shipping?: CheckoutCustomerContactInput; +} + +export interface CheckoutSessionPaymentMethodConfigInput { + processor?: string; + checkoutTypes?: string[]; +} + +export interface CheckoutSessionPaymentMethodsInput { + ach?: CheckoutSessionPaymentMethodConfigInput | null; + applePay?: CheckoutSessionPaymentMethodConfigInput | null; + card?: CheckoutSessionPaymentMethodConfigInput | null; + ccavenue?: CheckoutSessionPaymentMethodConfigInput | null; + express?: CheckoutSessionPaymentMethodConfigInput | null; + googlePay?: CheckoutSessionPaymentMethodConfigInput | null; + mercadopago?: CheckoutSessionPaymentMethodConfigInput | null; + offline?: CheckoutSessionPaymentMethodConfigInput | null; + paypal?: CheckoutSessionPaymentMethodConfigInput | null; + paze?: CheckoutSessionPaymentMethodConfigInput | null; +} + +export interface CheckoutAppearanceInput { + theme?: 'base' | 'orange' | 'purple' | string; + /** Checkout API expects camelCase CSS variable names. */ + variables?: Record; +} + +export interface CheckoutSessionShippingOptionsInput { + fulfillmentLocationId?: string; + originAddress?: Record; +} + +export interface CreateCheckoutSessionInput { + storeId: string; + returnUrl: string; + successUrl: string; + /** Use draftOrderId for a custom cart/draft-order checkout flow. */ + draftOrderId?: string; + /** Use lineItems for Buy Now or when building checkout from a custom cart. */ + lineItems?: CheckoutSessionLineItemInput[]; + channelId?: string; + sourceApp?: string; + owner?: string; + customerId?: string; + customer?: CheckoutCustomerInput; + storeName?: string; + environment?: string; + url?: string; + expiresAt?: string; + enabledLocales?: string[]; + enabledPaymentProviders?: string[]; + paymentMethods?: CheckoutSessionPaymentMethodsInput; + appearance?: CheckoutAppearanceInput; + enableAddressAutocomplete?: boolean; + enableBillingAddressCollection?: boolean; + enableLocalPickup?: boolean; + enableNotesCollection?: boolean; + enablePaymentMethodCollection?: boolean; + enablePhoneCollection?: boolean; + enablePromotionCodes?: boolean; + enableShipping?: boolean; + enableShippingAddressCollection?: boolean; + enableSurcharge?: boolean; + enableTaxCollection?: boolean; + enableTips?: boolean; + /** Merchant shipping options synchronized server-side from Commerce. */ + shipping?: CheckoutSessionShippingOptionsInput; + /** Escape hatch for newer checkout fields without updating this copy/paste kit. */ + [key: string]: unknown; +} + +export interface CheckoutPaymentMethodConfig { + processor?: string | null; + checkoutTypes?: string[] | null; +} + +export interface CheckoutSessionResult { + id?: string | null; + token?: string | null; + url?: string | null; + sourceApp?: string | null; + returnUrl?: string | null; + successUrl?: string | null; + storeId?: string | null; + businessId?: string | null; + channelId?: string | null; + customerId?: string | null; + storeName?: string | null; + environment?: string | null; + enableTips?: boolean | null; + enabledLocales?: string[] | null; + enableSurcharge?: boolean | null; + enableLocalPickup?: boolean | null; + enableShipping?: boolean | null; + enablePhoneCollection?: boolean | null; + enableNotesCollection?: boolean | null; + enablePromotionCodes?: boolean | null; + enableTaxCollection?: boolean | null; + enableShippingAddressCollection?: boolean | null; + enableBillingAddressCollection?: boolean | null; + enableAddressAutocomplete?: boolean | null; + paymentMethods?: Record | null; + draftOrder?: { + id?: string | null; + statuses?: Array<{ status?: string | null } | null> | null; + totals?: { + total?: Money | null; + } | null; + } | null; +} + +export interface CreateCheckoutSessionVariables { + input: CreateCheckoutSessionInput; +} + +export interface CreateCheckoutSessionResult { + createCheckoutSession?: CheckoutSessionResult | null; +} + +export interface CreateCheckoutSessionOptions { + input: CreateCheckoutSessionInput; + accessToken: string; + apiBaseUrl: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} + +export interface CreateCheckoutSessionWithClientCredentialsOptions { + input: CreateCheckoutSessionInput; + clientId: string; + clientSecret: string; + apiBaseUrl: string; + scope?: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} + +export interface BuyNowCheckoutInput { + storeId: string; + skuId: string; + quantity?: number; + returnUrl: string; + successUrl: string; + channelId?: string; +} + +export interface CartCheckoutInput { + storeId: string; + draftOrderId: string; + returnUrl: string; + successUrl: string; + channelId?: string; +} + +export interface NonCatalogCheckoutInput { + storeId: string; + lineItemData: CheckoutSessionLineItemDataInput; + quantity?: number; + returnUrl: string; + successUrl: string; + channelId?: string; +} + +export type CheckoutSessionOverrides = Partial< + Omit +>; + +function stripUndefined>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ) as Partial; +} + +function applyCheckoutDefaults( + base: CreateCheckoutSessionInput, + overrides: CheckoutSessionOverrides = {}, +): CreateCheckoutSessionInput { + const definedOverrides = stripUndefined(overrides); + const result: CreateCheckoutSessionInput = { + ...base, + ...definedOverrides, + }; + + // Backstop only — every caller in this file now sends paymentMethods + // explicitly (see DEFAULT_CHECKOUT_PAYMENT_METHODS above). Keep this for + // any other caller of these builders; do not treat it as the primary source + // of the default. + if (result.paymentMethods === undefined) { + result.paymentMethods = DEFAULT_CHECKOUT_PAYMENT_METHODS; + } + + if (result.enablePaymentMethodCollection === undefined) { + result.enablePaymentMethodCollection = true; + } + + if (result.enableBillingAddressCollection === undefined) { + result.enableBillingAddressCollection = true; + } + + if (result.enableShipping === undefined) { + result.enableShipping = false; + } + + if (result.enableShippingAddressCollection === undefined) { + result.enableShippingAddressCollection = false; + } + + if (result.enableLocalPickup === undefined) { + result.enableLocalPickup = false; + } + + if (result.enablePhoneCollection === undefined) { + result.enablePhoneCollection = false; + } + + if (result.enableTaxCollection === undefined) { + result.enableTaxCollection = false; + } + + return result; +} + +export function commerceApiEndpoint({ apiBaseUrl }: CommerceApiEndpointInput): string { + return new URL(apiBaseUrl).origin; +} + +export function checkoutGraphqlEndpoint({ apiBaseUrl }: CheckoutEndpointInput): string { + // Checkout uses a sibling subdomain of the configured API origin. + const { host, protocol } = new URL(apiBaseUrl); + return `${protocol}//checkout.commerce.${host}`; +} + +export function authorizationHeaders({ accessToken }: AuthorizationHeadersInput): HeadersInit { + return { + Authorization: `Bearer ${accessToken}`, + }; +} + +export async function getOAuthAccessToken({ + clientId, + clientSecret, + apiBaseUrl, + scope = 'commerce.product:read', + fetch: fetchImplementation, +}: OAuthTokenInput): Promise { + if (!clientId || !clientSecret) { + throw new Error('clientId and clientSecret are required'); + } + + const requestFetch = fetchImplementation ?? fetch; + const body = new URLSearchParams(); + body.append('grant_type', 'client_credentials'); + body.append('client_id', clientId); + body.append('client_secret', clientSecret); + body.append('scope', scope); + + const response = await requestFetch(new URL('/v2/oauth2/token', apiBaseUrl).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as OAuthTokenResponse; +} + +export function buildBuyNowCheckoutInput( + { storeId, skuId, quantity = 1, returnUrl, successUrl, channelId }: BuyNowCheckoutInput, + overrides: CheckoutSessionOverrides = {}, +): CreateCheckoutSessionInput { + const base: CreateCheckoutSessionInput = { + storeId, + returnUrl, + successUrl, + channelId, + lineItems: [{ skuId, quantity }], + }; + + return applyCheckoutDefaults(base, overrides); +} + +export function buildCartCheckoutInput( + { storeId, draftOrderId, returnUrl, successUrl, channelId }: CartCheckoutInput, + overrides: CheckoutSessionOverrides = {}, +): CreateCheckoutSessionInput { + const base: CreateCheckoutSessionInput = { + storeId, + returnUrl, + successUrl, + channelId, + draftOrderId, + }; + + return applyCheckoutDefaults(base, overrides); +} + +export function buildNonCatalogCheckoutInput( + { storeId, lineItemData, quantity = 1, returnUrl, successUrl, channelId }: NonCatalogCheckoutInput, + overrides: CheckoutSessionOverrides = {}, +): CreateCheckoutSessionInput { + const base: CreateCheckoutSessionInput = { + storeId, + returnUrl, + successUrl, + channelId, + lineItems: [{ lineItemData, quantity }], + }; + + return applyCheckoutDefaults(base, overrides); +} + +export const endpoints = { + commerceApi: commerceApiEndpoint, + checkoutGraphql: checkoutGraphqlEndpoint, +} as const; + +export const headers = { + authorization: authorizationHeaders, +} as const; + +export const helpers = { + buildBuyNowCheckoutInput, + buildCartCheckoutInput, + buildNonCatalogCheckoutInput, +} as const; diff --git a/packages/commerce-server/src/lib/commerce/config.ts b/packages/commerce-server/src/lib/commerce/config.ts new file mode 100644 index 00000000..19381526 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/config.ts @@ -0,0 +1,109 @@ +/** Server-only Commerce configuration. Hosts own secrets and deployment-specific loading. */ +import type { Response } from 'express'; +import { type CommerceCheckoutConfiguration, parseCommerceCheckoutConfiguration } from './checkout-config'; + +const DEFAULT_API_BASE_URL = 'https://api.godaddy.com'; + +export interface CommerceConfig { + /** Public OAuth client id. */ + clientId: string; + /** Server-only OAuth client secret. Never expose to the browser. */ + clientSecret: string; + storeId: string; + /** Sales channel used for orders and checkout. */ + channelId: string; + /** HTTPS API origin. The default configuration uses https://api.godaddy.com. */ + apiBaseUrl: string; + /** ISO 4217 currency code used when seeding empty cart totals. */ + currencyCode: string; + /** Optional host-owned checkout attribution. Never read from request bodies. */ + sourceApp?: string; + /** Optional host-owned attribution shared by draft orders and checkout. */ + owner?: string; +} + +export interface CommerceConfiguration { + read(): CommerceConfig; + readCheckout(): CommerceCheckoutConfiguration; +} + +export interface RuntimeCommerceConfigurationOptions { + /** Server environment containing credentials, store/channel IDs, currency, and checkout flags. */ + environment?: NodeJS.ProcessEnv; + /** Explicit server-controlled API origin override. Defaults to production. */ + apiBaseUrl?: string; + sourceApp?: string; + owner?: string; +} + +function normalizeApiBaseUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Commerce config: apiBaseUrl must be a valid HTTPS origin.'); + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error( + 'Commerce config: apiBaseUrl must be an HTTPS origin without credentials, path, query, or fragment.', + ); + } + return url.origin; +} + +function requireValue(environment: NodeJS.ProcessEnv, key: string): string { + const value = environment[key]?.trim(); + if (!value) throw new Error(`Commerce config: ${key} is missing. Configure it in the server environment.`); + return value; +} + +/** Read server environment values on each call. Loading files or secret stores belongs to the host. */ +export function readCommerceConfig(options: RuntimeCommerceConfigurationOptions = {}): CommerceConfig { + const environment = options.environment ?? process.env; + return { + clientId: requireValue(environment, 'GODADDY_OAUTH_CLIENT_ID'), + clientSecret: requireValue(environment, 'GODADDY_OAUTH_CLIENT_SECRET'), + storeId: requireValue(environment, 'GODADDY_STORE_ID'), + channelId: requireValue(environment, 'GODADDY_CHANNEL_ID'), + currencyCode: requireValue(environment, 'GODADDY_CURRENCY_CODE'), + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl ?? DEFAULT_API_BASE_URL), + ...(options.sourceApp ? { sourceApp: options.sourceApp } : {}), + ...(options.owner ? { owner: options.owner } : {}), + }; +} + +export function createRuntimeCommerceConfiguration( + options: RuntimeCommerceConfigurationOptions = {}, +): CommerceConfiguration { + return { + read: (): CommerceConfig => readCommerceConfig(options), + readCheckout: (): CommerceCheckoutConfiguration => + parseCommerceCheckoutConfiguration((options.environment ?? process.env).GODADDY_CHECKOUT_CONFIGURATION), + }; +} + +export function commerceConfigurationForResponse(res: Response): CommerceConfiguration { + const configuration: unknown = res.locals.commerceConfiguration; + if ( + configuration && + typeof configuration === 'object' && + 'read' in configuration && + typeof configuration.read === 'function' && + 'readCheckout' in configuration && + typeof configuration.readCheckout === 'function' + ) { + return configuration as CommerceConfiguration; + } + return createRuntimeCommerceConfiguration(); +} + +export function readCommerceConfigForResponse(res: Response): CommerceConfig { + return commerceConfigurationForResponse(res).read(); +} diff --git a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts new file mode 100644 index 00000000..a57e8bbc --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts @@ -0,0 +1,312 @@ +/** + * Server-side `createCheckoutSession` orchestrator. + * + * Mints an OAuth Bearer token (client_credentials grant) and creates a GoDaddy + * hosted checkout session. This is the single source of truth for the checkout + * call — both the `/api/commerce/checkout` HTTP route and any other server-side + * caller in the customer app (e.g. an appointment-booking handler that needs to + * charge a deposit) should call this function directly. Doing a same-origin + * loopback `fetch('/api/commerce/checkout', ...)` from inside another handler + * strips the inbound request's cookies/auth headers and burns an extra HTTP + * hop for no reason. + * + * Server-only. Credentials and integration settings come from the host configuration. + */ + +import { + authorizationHeaders, + buildBuyNowCheckoutInput, + buildCartCheckoutInput, + buildNonCatalogCheckoutInput, + type CheckoutSessionLineItemDataInput, + type CreateCheckoutSessionResult, + type CreateCheckoutSessionVariables, + checkoutGraphqlEndpoint, + DEFAULT_CHECKOUT_PAYMENT_METHODS, + getOAuthAccessToken, +} from './checkout-subgraph'; +import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { gqlRequest } from './gql'; + +export interface CreateCheckoutSessionParams { + /** Where the hosted checkout sends the shopper on cancel/back. */ + returnUrl: string; + /** Where the hosted checkout sends the shopper after payment. */ + successUrl: string; + /** Existing cart/draft order to convert to a checkout session. */ + draftOrderId?: string; + /** Buy-Now path; checkout is built from a single SKU line item. */ + skuId?: string; + /** Quantity for Buy-Now / non-catalog paths. Defaults to 1. */ + quantity?: number; + /** + * Non-catalog path; price the customer pays directly without a SKU or + * catalog product. Mutually exclusive with `skuId` and `draftOrderId`. + */ + lineItemData?: CheckoutSessionLineItemDataInput; +} + +export interface CheckoutSession { + /** Hosted checkout URL to redirect the shopper to. */ + url: string; + /** Checkout session id (not the order id). */ + id: string; + /** + * Draft order id when the API created one (cart + Buy-Now flows). `null` for + * non-catalog sessions, which have no draft order. + */ + draftOrderId: string | null; + /** Store id returned by checkout-api after it accepts the session. */ + storeId: string; + /** Sales channel id returned by checkout-api after it accepts the session. */ + channelId: string; + /** Commerce business that owns the selected store. */ + businessId: string | null; + /** Store display name returned by checkout-api. */ + storeName: string | null; + /** Server-owned source identifier used for transaction attribution. */ + sourceApp: string | null; +} + +// IMPORTANT: MutationCreateCheckoutSessionInput is the GDC checkout GraphQL schema type. +// Do NOT rename it to CreateCheckoutSessionInput (the local TypeScript interface name) — +// using the wrong name causes the GDC API to return an opaque "Internal server error". +const createCheckoutSessionMutation = ` + mutation CreateCheckoutSession($input: MutationCreateCheckoutSessionInput!) { + createCheckoutSession(input: $input) { + id + token + url + sourceApp + returnUrl + successUrl + storeId + businessId + channelId + customerId + storeName + environment + enableTips + enabledLocales + enableSurcharge + enableLocalPickup + enableShipping + enablePhoneCollection + enableNotesCollection + enablePromotionCodes + enableTaxCollection + enableShippingAddressCollection + enableBillingAddressCollection + enableAddressAutocomplete + paymentMethods { + card { + processor + checkoutTypes + } + ccavenue { + processor + checkoutTypes + } + express { + processor + checkoutTypes + } + applePay { + processor + checkoutTypes + } + googlePay { + processor + checkoutTypes + } + paypal { + processor + checkoutTypes + } + paze { + processor + checkoutTypes + } + offline { + processor + checkoutTypes + } + mercadopago { + processor + checkoutTypes + } + ach { + processor + checkoutTypes + } + } + draftOrder { + id + statuses { + status + } + totals { + total { + currencyCode + value + } + } + } + } + } +`; + +function promotionCodesEnabled(configuration: object): boolean { + return 'enablePromotionCodes' in configuration && configuration.enablePromotionCodes === true; +} + +export async function createCheckoutSession( + params: CreateCheckoutSessionParams, + configuration: CommerceConfiguration = createRuntimeCommerceConfiguration(), +): Promise { + const { draftOrderId, skuId, quantity, lineItemData, returnUrl, successUrl } = params; + + if (!returnUrl || !successUrl) { + throw new Error('createCheckoutSession: returnUrl and successUrl are required'); + } + + const checkoutSourceCount = [draftOrderId, skuId, lineItemData].filter(Boolean).length; + if (checkoutSourceCount !== 1) { + throw new Error('createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required'); + } + + const { + storeId, + channelId, + clientId, + clientSecret, + apiBaseUrl, + sourceApp, + owner, + currencyCode: configCurrencyCode, + } = configuration.read(); + const checkoutConfiguration = configuration.readCheckout(); + const enablePromotionCodes: boolean = promotionCodesEnabled(checkoutConfiguration); + const catalogShippingEnabled: boolean = lineItemData === undefined && checkoutConfiguration.enableShipping; + const checkoutOAuthScope: string = 'commerce.product:read'; + const token = await getOAuthAccessToken({ + clientId, + clientSecret, + apiBaseUrl, + scope: checkoutOAuthScope, + }); + + const attribution = { sourceApp, owner }; + const catalogCheckoutOverrides = { + ...attribution, + enablePromotionCodes, + enableTaxCollection: checkoutConfiguration.enableTaxCollection, + enableShipping: checkoutConfiguration.enableShipping, + enableShippingAddressCollection: checkoutConfiguration.enableShipping, + paymentMethods: DEFAULT_CHECKOUT_PAYMENT_METHODS, + shipping: catalogShippingEnabled ? checkoutConfiguration.shipping : undefined, + }; + + const resolvedLineItemData: typeof lineItemData = + lineItemData !== undefined + ? { + ...lineItemData, + priceData: { + ...lineItemData.priceData, + currencyCode: configCurrencyCode, + }, + } + : undefined; + + const input = draftOrderId + ? buildCartCheckoutInput( + { + storeId, + channelId, + draftOrderId, + returnUrl, + successUrl, + }, + catalogCheckoutOverrides, + ) + : resolvedLineItemData + ? buildNonCatalogCheckoutInput( + { + storeId, + channelId, + lineItemData: resolvedLineItemData, + quantity: quantity ?? 1, + returnUrl, + successUrl, + }, + { + ...attribution, + enableTaxCollection: checkoutConfiguration.enableTaxCollection, + paymentMethods: DEFAULT_CHECKOUT_PAYMENT_METHODS, + }, + ) + : buildBuyNowCheckoutInput( + { + storeId, + channelId, + skuId: skuId as string, + quantity: quantity ?? 1, + returnUrl, + successUrl, + }, + catalogCheckoutOverrides, + ); + + let result: CreateCheckoutSessionResult; + try { + result = await gqlRequest({ + endpoint: checkoutGraphqlEndpoint({ apiBaseUrl }), + query: createCheckoutSessionMutation, + variables: { input }, + headers: authorizationHeaders({ accessToken: token.access_token }), + }); + } catch (error) { + throw new Error('Commerce checkout session could not be created', { cause: error }); + } + + const session = result.createCheckoutSession; + if (!session?.url || !session.id) { + throw new Error('Checkout session was not created'); + } + + if (session.storeId !== storeId || session.channelId !== channelId) { + throw new Error( + `Checkout session binding mismatch: expected store ${storeId} and channel ${channelId}, received store ${session.storeId ?? 'missing'} and channel ${session.channelId ?? 'missing'}`, + ); + } + + const expectedShipping = lineItemData === undefined && checkoutConfiguration.enableShipping; + const expectedPromotionCodes: boolean = lineItemData === undefined && enablePromotionCodes; + if (expectedPromotionCodes && session.enablePromotionCodes !== true) { + throw new Error('Checkout session did not enable configured promotion codes'); + } + if (checkoutConfiguration.enableTaxCollection && session.enableTaxCollection !== true) { + throw new Error('Checkout session did not enable configured tax collection'); + } + if ( + expectedShipping && + (session.enableShipping !== true || session.enableShippingAddressCollection !== true) + ) { + throw new Error('Checkout session did not enable configured shipping and address collection'); + } + + if (!session.paymentMethods?.card?.processor) { + throw new Error('Checkout session did not configure payment methods.'); + } + + return { + url: session.url, + id: session.id, + draftOrderId: session.draftOrder?.id ?? null, + storeId, + channelId, + businessId: session.businessId ?? null, + storeName: session.storeName ?? null, + sourceApp: session.sourceApp ?? null, + }; +} diff --git a/packages/commerce-server/src/lib/commerce/get-order-status.ts b/packages/commerce-server/src/lib/commerce/get-order-status.ts new file mode 100644 index 00000000..a01c66a3 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/get-order-status.ts @@ -0,0 +1,83 @@ +/** + * Server-only order lookup using the authorized Commerce Orders REST API. + * Unlike the storefront cart API, this endpoint includes completed orders. + */ +import { authorizationHeaders, getOAuthAccessToken } from './checkout-subgraph'; +import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import type { Money } from './gql'; + +export interface CommerceOrderStatus { + /** GoDaddy order id. */ + id: string; + /** Payment status reported by Commerce, or 'unknown' when it is unavailable. */ + status: string; + /** Total amount in the currency's smallest unit (cents for USD). */ + amount: number; + /** ISO 4217 currency code (e.g. "USD"). */ + currency: string; + /** ISO 8601 creation timestamp. */ + createdAt?: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt?: string; + /** Line item summaries, excluding private order metadata. */ + lineItems?: unknown[]; +} + +interface OrderResponse { + order?: { + id: string; + context: { storeId: string; channelId: string }; + statuses?: { paymentStatus?: string | null }; + totals?: { total?: Money | null }; + createdAt?: string; + updatedAt?: string; + lineItems?: Array<{ id: string; title: string; quantity: number }>; + }; +} + +export async function getOrderStatus( + orderId: string, + configuration: CommerceConfiguration = createRuntimeCommerceConfiguration(), +): Promise { + if (typeof orderId !== 'string' || !orderId.trim() || orderId === '.' || orderId === '..') { + throw new Error('getOrderStatus: a valid orderId is required'); + } + + const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = configuration.read(); + const token = await getOAuthAccessToken({ + clientId, + clientSecret, + apiBaseUrl, + scope: 'commerce.order:read', + }); + const response = await fetch( + new URL( + `/v1/commerce/stores/${encodeURIComponent(storeId)}/orders/${encodeURIComponent(orderId)}`, + apiBaseUrl, + ), + { + method: 'GET', + headers: { ...authorizationHeaders({ accessToken: token.access_token }), Accept: 'application/json' }, + cache: 'no-store', + }, + ); + if (!response.ok) throw new Error(`Failed to load order: upstream returned ${response.status}`); + + const data = (await response.json()) as OrderResponse; + const order = data?.order; + if (!order?.id || order.id !== orderId) throw new Error('Order lookup did not return the requested order'); + if (order.context?.storeId !== storeId || order.context?.channelId !== channelId) { + throw new Error('Order lookup returned a different store or channel'); + } + const total = order.totals?.total; + + return { + id: order.id, + status: order.statuses?.paymentStatus ?? 'unknown', + amount: total?.value ?? 0, + currency: total?.currencyCode ?? currencyCode, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + lineItems: (order.lineItems ?? []).map(({ id, title, quantity }) => ({ id, name: title, quantity })), + }; +} diff --git a/packages/commerce-server/src/lib/commerce/gql.ts b/packages/commerce-server/src/lib/commerce/gql.ts new file mode 100644 index 00000000..25c36d7e --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/gql.ts @@ -0,0 +1,156 @@ +/** + * Shared GraphQL transport for the commerce kits. + * + * Server-only by convention — `gqlRequest` is invoked from the proxy routes + * under `src/server/api/commerce/**`. The error class and response shapes are + * isomorphic and safe to import from anywhere. + */ + +export type GraphQLVariables = Record; + +export interface GraphQLResponseError { + message?: string; + extensions?: { + code?: string; + status?: number; + http?: { + status?: number; + } | null; + } | null; +} + +export interface GraphQLResponse { + data?: TData; + errors?: GraphQLResponseError[]; +} + +export interface GqlRequestOptions { + endpoint: string; + query: string; + variables?: TVariables; + headers?: HeadersInit; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} + +export class GraphQLErrorWithCodes< + T extends { message?: string; code?: string; status?: number } = { + message?: string; + code?: string; + status?: number; + }, +> extends Error { + constructor( + public errors: T[], + public status?: number, + ) { + const errorMessage = + errors.length === 1 + ? `${errors[0]?.message || 'Unknown error'}` + : errors + .map((error) => error.message) + .filter(Boolean) + .join('; '); + + super(errorMessage); + this.name = 'GraphQLErrorWithCodes'; + } + + get codes(): string[] { + return this.errors.map((error) => error.code).filter(Boolean) as string[]; + } + + get messages(): string[] { + return this.errors.map((error) => error.message).filter(Boolean) as string[]; + } + + get statuses(): number[] { + return this.errors + .map((error) => error.status) + .filter((status): status is number => typeof status === 'number'); + } +} + +export interface Money { + value?: number | null; + currencyCode?: string | null; +} + +export interface StorefrontHeadersInput { + storeId: string; + clientId: string; +} + +export function storefrontHeaders({ storeId, clientId }: StorefrontHeadersInput): HeadersInit { + return { + 'X-Store-ID': storeId, + 'X-Client-ID': clientId, + }; +} + +export async function gqlRequest({ + endpoint, + query, + variables, + headers: headersInit, + fetch: fetchImplementation, +}: GqlRequestOptions): Promise { + const requestHeaders = new Headers(headersInit); + requestHeaders.set('Accept', 'application/json'); + requestHeaders.set('Content-Type', 'application/json'); + + const requestFetch = fetchImplementation ?? fetch; + const response = await requestFetch(endpoint, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ query, variables: variables ?? {} }), + cache: 'no-store', + }); + + let result: GraphQLResponse; + + try { + result = (await response.json()) as GraphQLResponse; + } catch { + throw new GraphQLErrorWithCodes( + [ + { + message: `GraphQL request failed: ${response.status} ${response.statusText}`, + status: response.status, + }, + ], + response.ok ? undefined : response.status, + ); + } + + if (result.errors?.length) { + throw new GraphQLErrorWithCodes( + result.errors.map((error) => ({ + message: error.message, + code: error.extensions?.code, + status: + error.extensions?.status ?? + error.extensions?.http?.status ?? + (response.ok ? undefined : response.status), + })), + response.ok ? undefined : response.status, + ); + } + + if (!response.ok) { + throw new GraphQLErrorWithCodes( + [ + { + message: `GraphQL request failed: ${response.status} ${response.statusText}`, + status: response.status, + }, + ], + response.status, + ); + } + + if (result.data === undefined) { + throw new Error('GraphQL response did not include data'); + } + + return result.data; +} diff --git a/packages/commerce-server/src/lib/commerce/order-subgraph.ts b/packages/commerce-server/src/lib/commerce/order-subgraph.ts new file mode 100644 index 00000000..7eef6751 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/order-subgraph.ts @@ -0,0 +1,586 @@ +/** + * GoDaddy Commerce order-storefront-subgraph kit. + * + * Used by the cart proxy routes under `src/server/api/commerce/cart/**` to + * read and mutate draft orders (carts). The wire transport (`gqlRequest`) + * lives in `gql.ts`; types and view-model helpers here are isomorphic and + * safe to import from client components for shaping props. + * + * Domain notes: + * - A "cart" is a draft order — same id throughout pending/paid lifecycle. + * The customer app owns persistence of `draftOrderId` (typically localStorage). + * - `addDraftOrder` creates a cart with optional initial line items. + * Subsequent additions go through `addLineItemBySkuId`. + * - Money values are integers in the currency's smallest unit (cents for USD). + */ + +import type { Money } from './gql'; + +export interface OrderStorefrontEndpointInput { + apiBaseUrl: string; +} + +export interface DraftOrderContext { + storeId?: string | null; + channelId?: string | null; + owner?: string | null; +} + +export interface OrderTotals { + subTotal?: Money | null; + shippingTotal?: Money | null; + taxTotal?: Money | null; + discountTotal?: Money | null; + productDiscountTotal?: Money | null; + shippingDiscountTotal?: Money | null; + feeTotal?: Money | null; + total?: Money | null; +} + +export interface LineItemTotals { + subTotal?: Money | null; + taxTotal?: Money | null; + discountTotal?: Money | null; + feeTotal?: Money | null; +} + +export interface CartSelectedOption { + attribute?: string | null; + values?: string[] | null; +} + +export interface CartSelectedAddonValue { + name?: string | null; + costAdjustment?: Money | null; +} + +export interface CartSelectedAddon { + attribute?: string | null; + sku?: string | null; + values?: CartSelectedAddonValue[] | null; +} + +export interface CartLineItemDetails { + productAssetUrl?: string | null; + sku?: string | null; + unitOfMeasure?: string | null; + selectedOptions?: CartSelectedOption[] | null; + selectedAddons?: CartSelectedAddon[] | null; +} + +export interface CartDiscount { + id?: string | null; + name?: string | null; + code?: string | null; + amount?: Money | null; + ratePercentage?: string | null; + appliedBeforeTax?: boolean | null; +} + +export interface CartTax { + id?: string | null; + name?: string | null; + amount?: Money | null; + ratePercentage?: string | null; + included?: boolean | null; + exempted?: boolean | null; +} + +export interface CartNote { + id?: string | null; + content?: string | null; + author?: string | null; + authorType?: string | null; + createdAt?: string | null; +} + +export interface CartLineItem { + id?: string | null; + name?: string | null; + quantity?: number | null; + skuId?: string | null; + type?: string | null; + fulfillmentMode?: string | null; + details?: CartLineItemDetails | null; + totals?: LineItemTotals | null; + discounts?: CartDiscount[] | null; + taxes?: CartTax[] | null; + notes?: CartNote[] | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface CartAddress { + addressLine1?: string | null; + addressLine2?: string | null; + addressLine3?: string | null; + adminArea1?: string | null; + adminArea2?: string | null; + adminArea3?: string | null; + adminArea4?: string | null; + postalCode?: string | null; + countryCode?: string | null; +} + +export interface CartShippingInfo { + firstName?: string | null; + lastName?: string | null; + email?: string | null; + phone?: string | null; + companyName?: string | null; + address?: CartAddress | null; +} + +export interface CartOrder { + id?: string | null; + customerId?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + context?: DraftOrderContext | null; + lineItems?: CartLineItem[] | null; + totals?: OrderTotals | null; + discounts?: CartDiscount[] | null; + taxes?: CartTax[] | null; + shipping?: CartShippingInfo | null; + notes?: CartNote[] | null; + tags?: string[] | null; +} + +export interface AddCartOrderInput { + customerId?: string | null; + context: DraftOrderContext; + totals: OrderTotals; + lineItems?: CreateDraftLineItemInput[] | null; +} + +export interface LineItemDetailsInput { + productAssetUrl?: string; + sku?: string; + unitOfMeasure?: string; + selectedOptions?: CartSelectedOption[]; + selectedAddons?: CartSelectedAddon[]; +} + +// Cart line item inputs come in two distinct GraphQL shapes that look +// superficially similar but are NOT interchangeable. Reusing one interface +// for both compiles cleanly but fails at runtime when the API rejects +// extraneous or missing fields. +// +// CreateDraftLineItemInput — nested inside `addDraftOrder`'s AddDraftOrderInput +// - No `orderId` (the order is being created in this call) +// - No `status` (server defaults it on the new draft) +// - Caller MUST supply `totals` and `unitAmount`; the server does not +// resolve pricing from the SKU during draft order creation +// +// AddLineItemBySkuIdInput — input to the standalone `addLineItemBySkuId` +// - Requires `orderId` (target an existing draft order) +// - Server looks up pricing from `skuId`; do NOT pass totals/unitAmount +// - Optional `status` may be passed when seeding +// +// Keep these as separate types so a misuse (e.g. passing an addLineItemBySkuId +// payload into addDraftOrder) is caught by the compiler. +export interface CreateDraftLineItemInput { + skuId: string; + name: string; + quantity: number; + unitAmount: Money; + totals: LineItemTotals; + fulfillmentMode?: string; + type?: string; + details?: LineItemDetailsInput; +} + +export interface AddLineItemBySkuIdInput { + orderId: string; + skuId: string; + name: string; + quantity: number; + fulfillmentMode?: string; + status?: string; + type?: string; + details?: LineItemDetailsInput; +} + +export interface UpdateLineItemByIdInput { + id: string; + orderId: string; + name?: string; + quantity?: number; + fulfillmentMode?: string; + status?: string; + type?: string; + details?: LineItemDetailsInput; +} + +export interface GetCartOrderVariables { + id: string; +} + +export interface DeleteLineItemByIdVariables { + id: string; + orderId: string; +} + +export interface AddCartOrderVariables { + input: AddCartOrderInput; +} + +export interface AddLineItemBySkuIdVariables { + input: AddLineItemBySkuIdInput; +} + +export interface UpdateLineItemByIdVariables { + input: UpdateLineItemByIdInput; +} + +export interface ApplyDiscountCodesInput { + orderId: string; + discountCodes: string[]; +} + +export interface ApplyDiscountCodesVariables { + input: ApplyDiscountCodesInput; +} + +export interface AddCartOrderResult { + addDraftOrder?: CartOrder | null; +} + +export interface AddLineItemBySkuIdResult { + addLineItemBySkuId?: CartLineItem | null; +} + +export interface UpdateLineItemByIdResult { + updateLineItemById?: CartLineItem | null; +} + +export interface DeleteLineItemByIdResult { + deleteLineItemById?: boolean | null; +} + +// The applyDiscountCodes mutation only selects `id` — the route discards this +// result and re-fetches the full cart. Do not widen this to CartOrder. +export interface ApplyDiscountCodesResult { + applyDiscountCodes?: { id?: string | null } | null; +} + +export interface GetCartOrderResult { + orderById?: CartOrder | null; +} + +export interface EmptyCartOrderInput { + storeId: string; + owner?: string; + channelId?: string; + currencyCode?: string; +} + +export interface AddToCartItemInput { + skuId: string; + name: string; + quantity: number; +} + +export interface CartSummaryTotals { + itemCount: number; + currencyCode: string; + subtotal: number; + shipping: number; + taxes: number; + discount: number; + total: number; +} + +function createMoney(value: number, currencyCode: string): { value: number; currencyCode: string } { + return { value, currencyCode }; +} + +export function orderStorefrontEndpoint({ apiBaseUrl }: OrderStorefrontEndpointInput): string { + return new URL('/v1/commerce/order-storefront-subgraph', apiBaseUrl).toString(); +} + +export function buildEmptyCartOrderInput({ + storeId, + channelId, + currencyCode = 'USD', + owner, +}: EmptyCartOrderInput): AddCartOrderInput { + return { + context: { + storeId, + channelId: channelId || '', + ...(owner ? { owner } : {}), + }, + totals: { + subTotal: createMoney(0, currencyCode), + shippingTotal: createMoney(0, currencyCode), + discountTotal: createMoney(0, currencyCode), + feeTotal: createMoney(0, currencyCode), + taxTotal: createMoney(0, currencyCode), + total: createMoney(0, currencyCode), + }, + }; +} + +export function buildAddLineItemBySkuIdInput( + orderId: string, + item: AddToCartItemInput, +): AddLineItemBySkuIdInput { + return { + orderId, + skuId: item.skuId, + name: item.name, + quantity: item.quantity, + fulfillmentMode: 'NONE', + status: 'DRAFT', + }; +} + +// Use only when seeding a brand-new draft order via `addDraftOrder`. Caller +// must supply pricing (`unitAmount` + `totals`) because the draft-order +// mutation does not resolve SKU prices server-side. For adding items to an +// already-existing cart, use `buildAddLineItemBySkuIdInput` instead. +export interface BuildCreateDraftLineItemInputOptions { + item: AddToCartItemInput; + unitAmount: Money; + totals?: LineItemTotals; + fulfillmentMode?: string; +} + +export function buildCreateDraftLineItemInput({ + item, + unitAmount, + totals, + fulfillmentMode = 'NONE', +}: BuildCreateDraftLineItemInputOptions): CreateDraftLineItemInput { + const currencyCode = unitAmount.currencyCode || 'USD'; + const unitValue = unitAmount.value || 0; + return { + skuId: item.skuId, + name: item.name, + quantity: item.quantity, + unitAmount, + totals: totals ?? { + subTotal: createMoney(unitValue * item.quantity, currencyCode), + taxTotal: createMoney(0, currencyCode), + discountTotal: createMoney(0, currencyCode), + feeTotal: createMoney(0, currencyCode), + }, + fulfillmentMode, + }; +} + +/** + * Add one item to the cart. When `cartId` is null a new cart is created; + * otherwise the item is appended to the existing cart. Both paths accept the + * same `AddToCartItemInput` shape and return `{ cart: CartOrder | null }`. + * + * Use this helper everywhere instead of calling the two routes directly so + * the payload shape stays consistent and callers can't accidentally diverge. + */ +export async function addToCart( + cartId: string | null, + item: AddToCartItemInput, + fetchFn: typeof globalThis.fetch = globalThis.fetch, +): Promise<{ cart: CartOrder | null }> { + const url = cartId ? `/api/commerce/cart/${cartId}/items` : '/api/commerce/cart'; + const body = cartId ? item : { lineItems: [item] }; + const res = await fetchFn(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json() as Promise<{ cart: CartOrder | null }>; +} + +export function getCartSummaryTotals(order: CartOrder | null | undefined): CartSummaryTotals { + const lineItems = order?.lineItems ?? []; + const currencyCode = order?.totals?.total?.currencyCode || 'USD'; + + return { + itemCount: lineItems.reduce((sum, item) => sum + (item.quantity || 0), 0), + currencyCode, + subtotal: order?.totals?.subTotal?.value || 0, + shipping: order?.totals?.shippingTotal?.value || 0, + taxes: order?.totals?.taxTotal?.value || 0, + discount: order?.totals?.discountTotal?.value || 0, + total: order?.totals?.total?.value || 0, + }; +} + +// Read-only receipt enrichment. The order storefront schema does not expose +// payment status; neither this query nor cart hydration can prove payment. +// Full draft-order query — mirrored across the cart proxy routes so every +// mutation can re-fetch the cart and return a fully-populated `CartOrder` +// (totals, taxes, discounts) under a single canonical `orderById` key. +// The individual mutations (`addLineItemBySkuId`, `updateLineItemById`, +// `deleteLineItemById`, `applyDiscountCodes`) return either a `CartLineItem` +// or a partial order shape that omits order-level totals — never trust the +// raw mutation response for state. +export const getCartOrderQuery = ` + query GetCartOrder($id: ID!) { + orderById(id: $id) { + id + customerId + createdAt + updatedAt + context { + storeId + channelId + } + lineItems { + id + name + quantity + skuId + type + fulfillmentMode + details { + productAssetUrl + sku + unitOfMeasure + selectedOptions { + attribute + values + } + selectedAddons { + attribute + sku + values { + name + costAdjustment { + value + currencyCode + } + } + } + } + totals { + subTotal { + value + currencyCode + } + taxTotal { + value + currencyCode + } + discountTotal { + value + currencyCode + } + feeTotal { + value + currencyCode + } + } + discounts { + id + name + code + amount { + value + currencyCode + } + ratePercentage + } + taxes { + id + name + amount { + value + currencyCode + } + ratePercentage + } + notes { + id + content + author + authorType + } + } + totals { + subTotal { + value + currencyCode + } + shippingTotal { + value + currencyCode + } + taxTotal { + value + currencyCode + } + discountTotal { + value + currencyCode + } + productDiscountTotal { + value + currencyCode + } + shippingDiscountTotal { + value + currencyCode + } + feeTotal { + value + currencyCode + } + total { + value + currencyCode + } + } + discounts { + id + name + code + amount { + value + currencyCode + } + ratePercentage + appliedBeforeTax + } + taxes { + id + name + amount { + value + currencyCode + } + ratePercentage + included + exempted + } + shipping { + firstName + lastName + email + phone + companyName + address { + addressLine1 + addressLine2 + addressLine3 + adminArea1 + adminArea2 + adminArea3 + adminArea4 + postalCode + countryCode + } + } + notes { + id + content + author + authorType + createdAt + } + tags + } + } +`; diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts new file mode 100644 index 00000000..3bb8bd54 --- /dev/null +++ b/packages/commerce-server/src/router.test.ts @@ -0,0 +1,351 @@ +import type { AddressInfo } from 'node:net'; +import type { Request, Response } from 'express'; +import express from 'express'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCommerceCartScope } from './lib/commerce/cart-scope'; +import { createCheckoutSession } from './lib/commerce/create-checkout-session'; +import { GraphQLErrorWithCodes, gqlRequest } from './lib/commerce/gql'; +import { getCartOrderQuery } from './lib/commerce/order-subgraph'; +import { createCommerceCatalogRouter, createGoDaddyPaymentsRouter } from './router'; +import applyDiscount from './server/api/commerce/cart/[id]/discounts/POST'; +import readCart from './server/api/commerce/cart/[id]/GET'; +import deleteItem from './server/api/commerce/cart/[id]/items/[itemId]/DELETE'; +import updateItem from './server/api/commerce/cart/[id]/items/[itemId]/PATCH'; +import addItem from './server/api/commerce/cart/[id]/items/POST'; +import createCart from './server/api/commerce/cart/POST'; +import checkout from './server/api/commerce/checkout/POST'; +import configHandler from './server/api/commerce/config/GET'; +import readProduct from './server/api/commerce/products/[id]/GET'; +import readProducts from './server/api/commerce/products/GET'; +import readSku from './server/api/commerce/skus/[id]/GET'; + +vi.mock('./lib/commerce/gql', async (importOriginal) => ({ + ...(await importOriginal()), + gqlRequest: vi.fn(), + storefrontHeaders: vi.fn(() => ({})), +})); +vi.mock('./lib/commerce/create-checkout-session', () => ({ + createCheckoutSession: vi.fn(), +})); + +const binding = { + apiBaseUrl: 'https://api.godaddy.com', + storeId: 'store-1', + channelId: 'channel-1', + currencyCode: 'USD', + clientId: 'client-1', + clientSecret: 'server-only-secret', +}; +const configuration = { + read: (): typeof binding => binding, + readCheckout: (): { enablePromotionCodes: false; enableTaxCollection: false; enableShipping: false } => ({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + }), +}; + +function response() { + const res = { + status: vi.fn(), + json: vi.fn(), + setHeader: vi.fn(), + locals: { commerceConfiguration: configuration }, + }; + res.status.mockReturnValue(res); + return res; +} + +describe('Commerce scoped routes', () => { + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it('does not query unsupported status fields on the storefront cart API', (): void => { + expect(getCartOrderQuery).not.toMatch(/\bstatuses\s*\{/); + }); + + it.each([readCart, addItem, updateItem, deleteItem, applyDiscount, readProduct, readSku])( + 'rejects array route IDs before an upstream request', + async (handler): Promise => { + const res: ReturnType = response(); + await handler( + { + params: { id: ['one', 'two'], itemId: 'item' }, + headers: {}, + query: {}, + body: { skuId: 'sku', name: 'Product', quantity: 1, discountCodes: ['TEST'] }, + } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalledWith(400); + expect(gqlRequest).not.toHaveBeenCalled(); + }, + ); + + it('includes selected SKU data and preserves attribute-value name filters', async (): Promise => { + const res: ReturnType = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({ skuGroup: { id: 'product' } }); + await readProduct( + { + params: { id: 'product' }, + query: { attributeValues: ['red', 'large'] }, + } as unknown as Request, + res as unknown as Response, + ); + expect(gqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ variables: { id: 'product', attributeValues: ['red', 'large'] } }), + ); + const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? ''; + expect(query).toContain('prices(first: 10)'); + expect(query).toContain('inventoryCounts'); + expect(query).toContain('pageInfo { hasNextPage }'); + expect(res.json).toHaveBeenCalledWith({ skuGroup: { id: 'product' } }); + }); + + it.each([readProducts, readProduct, readSku])( + 'keeps catalog queries within the upstream depth limit of 10', + async (handler: typeof readProducts): Promise => { + const res: ReturnType = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({}); + await handler( + { params: { id: 'product' }, query: {} } as unknown as Request, + res as unknown as Response, + ); + const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? ''; + // These queries are inline selections. Exclude arguments from brace depth; + // fragments require expansion, so reject them rather than undercounting. + expect(query).not.toContain('...'); + const selections: string = query.replace(/#[^\n]*|"(?:\\.|[^"\\])*"|\([^()]*\)/g, ''); + let depth: number = 0; + let maximum: number = 0; + for (const brace of selections.match(/[{}]/g) ?? []) { + depth += brace === '{' ? 1 : -1; + maximum = Math.max(maximum, depth); + } + expect(depth).toBe(0); + expect(maximum).toBeGreaterThan(0); + expect(maximum).toBeLessThanOrEqual(10); + }, + ); + + it('returns only public binding data and disables caching', async (): Promise => { + const res = response(); + await configHandler({} as Request, res as unknown as Response); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(res.json).toHaveBeenCalledWith({ + cartScope: getCommerceCartScope(binding), + currencyCode: 'USD', + }); + }); + + it('fails visibly on an incomplete binding without a fallback currency', async (): Promise => { + const res = response(); + res.locals.commerceConfiguration = { + ...configuration, + read: (): never => { + throw new Error('Missing channel'); + }, + }; + await configHandler({} as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ + error: expect.any(String), + message: 'Missing channel', + }); + }); + + it.each([ + ['create', createCart], + ['read', readCart], + ['add', addItem], + ['update', updateItem], + ['remove', deleteItem], + ['discount', applyDiscount], + ['checkout', checkout], + ['products', readProducts], + ['product', readProduct], + ['sku', readSku], + ] as const)('blocks a stale binding before upstream %s calls', async (_name, handler): Promise => { + const res = response(); + const req = { + headers: { 'x-commerce-scope': 'old-binding' }, + query: {}, + params: { id: 'cart-1', itemId: 'item-1' }, + body: { + skuId: 'sku-1', + name: 'Product', + quantity: 1, + discountCodes: ['PROMO'], + returnUrl: 'https://store.example/shop', + successUrl: 'https://store.example/return', + }, + } as unknown as Request; + await handler(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(409); + expect(gqlRequest).not.toHaveBeenCalled(); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }); + + it.each([readProducts, readProduct, readSku])( + 'accepts current and omitted catalog scopes', + async (handler): Promise => { + for (const scope of [undefined, getCommerceCartScope(binding)]) { + const res = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({}); + await handler( + { + headers: { 'x-commerce-scope': scope }, + params: { id: 'product-1' }, + query: {}, + } as unknown as Request, + res as unknown as Response, + ); + expect(res.json).toHaveBeenCalledWith({}); + expect(res.status).not.toHaveBeenCalled(); + } + expect(gqlRequest).toHaveBeenCalledTimes(2); + }, + ); + + it('updates only the URL cart/item and forwards only supported body fields', async (): Promise => { + const res = response(); + const fields = { + name: 'Updated product', + quantity: 2, + fulfillmentMode: 'NONE', + status: 'DRAFT', + type: 'PRODUCT', + details: { sku: 'sku-1' }, + }; + const cart = { id: 'cart-1', lineItems: [{ id: 'item-1', ...fields }] }; + vi.mocked(gqlRequest) + .mockResolvedValueOnce({ updateLineItemById: { id: 'item-1' } }) + .mockResolvedValueOnce({ orderById: cart }); + await updateItem( + { + headers: {}, + params: { id: 'cart-1', itemId: 'item-1' }, + body: { ...fields, id: 'other-item', orderId: 'other-cart', unitAmount: { value: 1 } }, + } as unknown as Request, + res as unknown as Response, + ); + expect(vi.mocked(gqlRequest).mock.calls[0]?.[0].variables).toEqual({ + input: { id: 'item-1', orderId: 'cart-1', ...fields }, + }); + expect(vi.mocked(gqlRequest).mock.calls[1]?.[0].variables).toEqual({ id: 'cart-1' }); + expect(res.json).toHaveBeenCalledWith({ cart }); + }); + + it.each([ + new GraphQLErrorWithCodes([{ code: 'UNAUTHENTICATED', message: 'Authentication token expired' }], 401), + new GraphQLErrorWithCodes([ + { code: 'UNAUTHENTICATED', message: 'Authentication token expired', status: 401 }, + ]), + new GraphQLErrorWithCodes([{ message: 'Session expired' }]), + new GraphQLErrorWithCodes([{ code: 'INTERNAL_SERVER_ERROR', message: 'Authentication token expired' }]), + new GraphQLErrorWithCodes([{ code: 'INTERNAL_SERVER_ERROR', message: 'Database unavailable' }]), + new GraphQLErrorWithCodes([{ code: 'UNAUTHENTICATED', message: 'Order not found' }]), + new GraphQLErrorWithCodes([{ code: 'NOT_FOUND', message: 'Store not found' }]), + new GraphQLErrorWithCodes([{ message: 'GraphQL endpoint not found', status: 404 }], 404), + new GraphQLErrorWithCodes([{ code: 'ORDER_EXPIRED', message: 'Order expired' }], 503), + new GraphQLErrorWithCodes([ + { code: 'ORDER_NOT_FOUND', message: 'Order not found' }, + { code: 'FORBIDDEN', message: 'Access denied', status: 403 }, + ]), + ])('preserves the cart on unrelated upstream failures: %s', async (error): Promise => { + const res = response(); + vi.mocked(gqlRequest).mockRejectedValueOnce(error); + await readCart( + { headers: {}, params: { id: 'cart-1' } } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Failed to load cart', message: error.message }); + }); + + it.each([ + new GraphQLErrorWithCodes([{ code: 'ORDER_NOT_FOUND' }], 404), + new GraphQLErrorWithCodes([{ code: 'INTERNAL_SERVER_ERROR', message: 'Order not found' }]), + new GraphQLErrorWithCodes([{ code: 'CART_EXPIRED' }], 410), + new GraphQLErrorWithCodes([{ code: 'DRAFT_ORDER_NOT_FOUND' }]), + new GraphQLErrorWithCodes([{ code: 'NOT_FOUND', message: 'Order not found: cart-1' }]), + new GraphQLErrorWithCodes([{ message: 'Cart has expired' }]), + ])('clears a missing or expired cart: %s', async (error): Promise => { + const res = response(); + vi.mocked(gqlRequest).mockRejectedValueOnce(error); + await readCart( + { headers: {}, params: { id: 'cart-1' } } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ cart: null }); + }); + + it.each([undefined, getCommerceCartScope(binding)])( + 'preserves cart creation with scope %s', + async (scope): Promise => { + vi.mocked(gqlRequest) + .mockResolvedValueOnce({ addDraftOrder: { id: 'cart-1' } }) + .mockResolvedValueOnce({ addLineItemBySkuId: { id: 'item-1' } }) + .mockResolvedValueOnce({ + orderById: { id: 'cart-1', lineItems: [{ skuId: 'sku-1', quantity: 1 }] }, + }); + const res = response(); + await createCart( + { + headers: { 'x-commerce-scope': scope }, + body: { lineItems: [{ skuId: 'sku-1', name: 'Product', quantity: 1 }] }, + } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(gqlRequest).toHaveBeenCalledTimes(3); + expect(res.json).toHaveBeenCalledWith({ + cart: { id: 'cart-1', lineItems: [{ skuId: 'sku-1', quantity: 1 }] }, + }); + }, + ); +}); + +describe('Commerce router mounting', (): void => { + it('mounts the catalog contract below the host path', async (): Promise => { + const app = express(); + app.use('/api/commerce', createCommerceCatalogRouter(configuration)); + const server = app.listen(0); + try { + const address = server.address() as AddressInfo; + const result = await fetch(`http://127.0.0.1:${address.port}/api/commerce/config`); + expect(result.status).toBe(200); + await expect(result.json()).resolves.toEqual({ + cartScope: getCommerceCartScope(binding), + currencyCode: 'USD', + }); + } finally { + await new Promise((resolve, reject): void => { + server.close((error?: Error): void => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); + + it('does not install catalog routes in the payments-only preset', async (): Promise => { + const app = express(); + app.use('/api/commerce', createGoDaddyPaymentsRouter(configuration)); + const server = app.listen(0); + try { + const address = server.address() as AddressInfo; + const result = await fetch(`http://127.0.0.1:${address.port}/api/commerce/config`); + expect(result.status).toBe(404); + } finally { + await new Promise((resolve, reject): void => { + server.close((error?: Error): void => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); +}); diff --git a/packages/commerce-server/src/router.ts b/packages/commerce-server/src/router.ts new file mode 100644 index 00000000..ede55f5a --- /dev/null +++ b/packages/commerce-server/src/router.ts @@ -0,0 +1,82 @@ +import { type RequestHandler, Router } from 'express'; +import { + type CheckoutReturnUrls, + createCheckoutReturnUrlValidator, +} from './lib/commerce/checkout-return-urls'; +import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './lib/commerce/config'; +import cartDiscountPost from './server/api/commerce/cart/[id]/discounts/POST'; +import cartGet from './server/api/commerce/cart/[id]/GET'; +import cartItemDelete from './server/api/commerce/cart/[id]/items/[itemId]/DELETE'; +import cartItemPatch from './server/api/commerce/cart/[id]/items/[itemId]/PATCH'; +import cartItemPost from './server/api/commerce/cart/[id]/items/POST'; +import cartPost from './server/api/commerce/cart/POST'; +import checkoutPost from './server/api/commerce/checkout/POST'; +import configGet from './server/api/commerce/config/GET'; +import orderStatusGet from './server/api/commerce/order-status/GET'; +import productGet from './server/api/commerce/products/[id]/GET'; +import productsGet from './server/api/commerce/products/GET'; +import skuGet from './server/api/commerce/skus/[id]/GET'; + +export interface CommerceRouterFeatures { + catalog?: boolean; + payments?: boolean; +} + +export interface CreateCommerceRouterOptions { + configuration?: CommerceConfiguration; + features?: CommerceRouterFeatures; + /** Required to enable browser checkout; never derived from request headers. */ + checkoutReturnUrls?: CheckoutReturnUrls; +} + +export function createCommerceRouter(options: CreateCommerceRouterOptions = {}): Router { + const router: Router = Router(); + const configuration: CommerceConfiguration = options.configuration ?? createRuntimeCommerceConfiguration(); + const catalogEnabled: boolean = options.features?.catalog ?? true; + const paymentsEnabled: boolean = options.features?.payments ?? true; + + const validateCheckoutReturnUrls = options.checkoutReturnUrls + ? createCheckoutReturnUrlValidator(options.checkoutReturnUrls) + : undefined; + + router.use((_req, res, next): void => { + res.locals.commerceConfiguration = configuration; + res.locals.commerceCheckoutReturnUrlValidator = validateCheckoutReturnUrls; + next(); + }); + + if (catalogEnabled) { + router.get('/config', configGet as RequestHandler); + router.get('/products', productsGet as RequestHandler); + router.get('/products/:id', productGet as RequestHandler); + router.get('/skus/:id', skuGet as RequestHandler); + router.post('/cart', cartPost as RequestHandler); + router.get('/cart/:id', cartGet as RequestHandler); + router.post('/cart/:id/items', cartItemPost as RequestHandler); + router.patch('/cart/:id/items/:itemId', cartItemPatch as RequestHandler); + router.delete('/cart/:id/items/:itemId', cartItemDelete as RequestHandler); + router.post('/cart/:id/discounts', cartDiscountPost as RequestHandler); + } + + if (paymentsEnabled) { + router.post('/checkout', checkoutPost as RequestHandler); + router.get('/order-status', orderStatusGet as RequestHandler); + } + + return router; +} + +export function createCommerceCatalogRouter(configuration?: CommerceConfiguration): Router { + return createCommerceRouter({ configuration, features: { catalog: true, payments: false } }); +} + +export function createGoDaddyPaymentsRouter( + configuration?: CommerceConfiguration, + checkoutReturnUrls?: CheckoutReturnUrls, +): Router { + return createCommerceRouter({ + configuration, + checkoutReturnUrls, + features: { catalog: false, payments: true }, + }); +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/POST.ts new file mode 100644 index 00000000..c249a905 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/POST.ts @@ -0,0 +1,143 @@ +/** + * POST /api/commerce/cart + * + * Create a draft order (cart). The first "Add to cart" click in the UI + * should call this route with the first line item(s); the response includes + * the persistent cart id (`data.cart.id`) that the client stores in + * localStorage (keyed per store/channel) for subsequent operations. + * + * IMPORTANT — two-step pattern: + * `addDraftOrder` cannot accept SKU-based line items inline. Its + * `CreateDraftLineItemInput` requires `totals` + `unitAmount` (pricing + * the client doesn't have); only `addLineItemBySkuId` resolves pricing + * server-side. So we always: + * 1. Create an EMPTY cart via `addDraftOrder`. + * 2. Add each initial SKU via `addLineItemBySkuId` against the new id. + * 3. Re-fetch the cart so the response carries hydrated totals/items. + * + * Body: + * currencyCode? - override the configured currency (defaults to GODADDY_CURRENCY_CODE) + * lineItems? - [{ skuId, name, quantity }] added one-by-one after create + * + * Response: { cart: CartOrder | null } + * Same shape as GET /api/commerce/cart/:id and the other cart mutation + * routes. Clients should always read `data.cart` and never the raw + * GraphQL field names like `addDraftOrder` or `orderById`. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type AddCartOrderResult, + type AddCartOrderVariables, + type AddLineItemBySkuIdResult, + type AddLineItemBySkuIdVariables, + type AddToCartItemInput, + buildAddLineItemBySkuIdInput, + buildEmptyCartOrderInput, + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, +} from '@/lib/commerce/order-subgraph'; + +const addCartOrderMutation = ` + mutation AddCartOrder($input: AddDraftOrderInput!) { + addDraftOrder(input: $input) { + id + } + } +`; + +const addLineItemBySkuIdMutation = ` + mutation AddLineItemBySkuId($input: AddLineItemInput!) { + addLineItemBySkuId(input: $input) { + id + } + } +`; + +interface CreateCartBody { + currencyCode?: string; + lineItems?: AddToCartItemInput[]; +} + +export default async function handler(req: Request, res: Response): Promise { + try { + const body = (req.body ?? {}) as CreateCartBody; + const initialItems = Array.isArray(body.lineItems) ? body.lineItems : []; + + // Validate all items BEFORE creating the cart so a bad payload can't + // produce an orphaned cart (cart created, item add fails, client never + // gets the id). Mirrors the validation in POST /api/commerce/cart/:id/items. + for (const item of initialItems) { + if (!item.skuId || !item.name || typeof item.quantity !== 'number') { + res.status(400).json({ + error: 'Each lineItem must have skuId, a non-empty name, and a numeric quantity', + }); + return; + } + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, channelId, clientId, apiBaseUrl, currencyCode } = config; + + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); + + // Step 1: create an EMPTY cart. Do not pass `lineItems` here — the + // `addDraftOrder` mutation expects `CreateDraftLineItemInput` (with + // server-priced `totals` + `unitAmount`), which the client cannot + // produce. SKU-based adds must go through `addLineItemBySkuId`. + const created = await gqlRequest({ + endpoint, + query: addCartOrderMutation, + variables: { + input: buildEmptyCartOrderInput({ + storeId, + channelId, + owner: config.owner, + currencyCode: body.currencyCode ?? currencyCode, + }), + }, + headers, + }); + + const newCartId = created.addDraftOrder?.id; + if (!newCartId) { + res.status(500).json({ error: 'Failed to create cart: missing id in mutation response' }); + return; + } + + // Step 2: append each initial SKU one-by-one via `addLineItemBySkuId`. + // Sequential, not parallel — same-cart line item adds are not safe to + // race on the server side. + for (const item of initialItems) { + await gqlRequest({ + endpoint, + query: addLineItemBySkuIdMutation, + variables: { input: buildAddLineItemBySkuIdInput(newCartId, item) }, + headers, + }); + } + + // Step 3: re-hydrate the cart and return it under the canonical `cart` + // key. Clients always read `data.cart` regardless of which cart route + // they called. + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: newCartId }, + headers, + }); + + res.status(201).json({ cart: hydrated.orderById ?? null }); + } catch (error) { + res.status(500).json({ + error: 'Failed to create cart', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts new file mode 100644 index 00000000..436a2900 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts @@ -0,0 +1,87 @@ +/** + * GET /api/commerce/cart/:id + * + * Read the current cart (draft order). Call on app load with the + * `draftOrderId` persisted in localStorage to hydrate the cart UI. + * + * Response: { cart: CartOrder | null } + * - The route normalises the GraphQL `orderById` field into a stable + * `cart` key so the client never has to know which subgraph query + * produced the data. Read `data.cart`, not `data.orderById` and not + * `data.getDraftOrder`. + * - Pass `cart` (when non-null) to `getCartSummaryTotals` for view-model + * totals. + * - When the cart is not found or has expired, `cart` is `null` and the + * HTTP status is still 200 (not 404). The client should check `cart === null` + * and clear the persisted `draftOrderId` when that is the case. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { GraphQLErrorWithCodes, gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, +} from '@/lib/commerce/order-subgraph'; + +function isCartNotFoundError(error: unknown): boolean { + if (!(error instanceof GraphQLErrorWithCodes)) { + return false; + } + + // A transport failure or an unrelated GraphQL error must not erase a saved cart. + // A bare HTTP 404 can also mean the upstream endpoint itself is unavailable. + const isFailure = (status: number | undefined): boolean => + status !== undefined && status >= 400 && status !== 404 && status !== 410; + if (isFailure(error.status) || error.errors.length === 0) return false; + + return error.errors.every(({ code, message, status }): boolean => { + if (isFailure(status)) return false; + // Orders throws this exact message for absent or non-draft orders; Apollo + // supplies its generic code rather than a domain-specific not-found code. + if (code === 'INTERNAL_SERVER_ERROR' && message === 'Order not found') return true; + if (code && /^(?:DRAFT[_-]?)?(?:ORDER|CART)[_-]?(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return true; + if (code && !/^(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return false; + return /\b(?:cart|(?:draft[ -])?order)\s+(?:(?:is|was|has)\s+)?(?:not found|expired)\b/i.test( + message ?? '', + ); + }); +} + +export default async function handler(req: Request, res: Response): Promise { + try { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + res.status(400).json({ error: 'Missing cart id' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + + const data = await gqlRequest({ + endpoint: orderStorefrontEndpoint({ apiBaseUrl }), + query: getCartOrderQuery, + variables: { id: cartId }, + headers: storefrontHeaders({ storeId, clientId }), + }); + + // Normalise to a stable `cart` key so client code never has to know that + // the underlying GraphQL field is `orderById`. See docblock at the top + // of this file. + res.json({ cart: data.orderById ?? null }); + } catch (error) { + if (isCartNotFoundError(error)) { + res.status(200).json({ cart: null }); + return; + } + + res.status(500).json({ + error: 'Failed to load cart', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts new file mode 100644 index 00000000..d995e73b --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts @@ -0,0 +1,86 @@ +/** + * POST /api/commerce/cart/:id/discounts + * + * Apply one or more promo codes to the cart. Optional — only wire up if + * the design includes a promo-code field in the cart drawer. + * + * Body: { discountCodes: string[] } + * + * Response: { cart: CartOrder | null } + * `applyDiscountCodes` returns a partial CartOrder shape (id, discounts, + * totals only). Rather than rely on that subset, the route discards it + * and re-fetches the full cart via `orderById`, returning it under the + * same `cart` key as GET /api/commerce/cart/:id so clients only ever + * read one shape. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type ApplyDiscountCodesResult, + type ApplyDiscountCodesVariables, + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, +} from '@/lib/commerce/order-subgraph'; + +const applyDiscountCodesMutation = ` + mutation ApplyDiscountCodes($input: ApplyDiscountCodesInput!) { + applyDiscountCodes(input: $input) { + id + } + } +`; + +interface ApplyDiscountsBody { + discountCodes?: unknown; +} + +export default async function handler(req: Request, res: Response): Promise { + try { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + res.status(400).json({ error: 'Missing cart id' }); + return; + } + + const body = (req.body ?? {}) as ApplyDiscountsBody; + const codes = Array.isArray(body.discountCodes) + ? body.discountCodes.filter((code): code is string => typeof code === 'string' && code.length > 0) + : []; + + if (codes.length === 0) { + res.status(400).json({ error: 'discountCodes must be a non-empty string array' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); + + await gqlRequest({ + endpoint, + query: applyDiscountCodesMutation, + variables: { input: { orderId: cartId, discountCodes: codes } }, + headers, + }); + + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); + + res.json({ cart: hydrated.orderById ?? null }); + } catch (error) { + res.status(500).json({ + error: 'Failed to apply discount codes', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts new file mode 100644 index 00000000..cc09744d --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts @@ -0,0 +1,90 @@ +/** + * POST /api/commerce/cart/:id/items + * + * Append a line item to an existing cart. Use after the first add-to-cart + * (which creates the cart via POST /api/commerce/cart); subsequent adds + * for the same cart go here. + * + * Body: { skuId: string, name: string, quantity: number } + * + * Response: { cart: CartOrder | null } + * The underlying `addLineItemBySkuId` GraphQL mutation only returns the + * newly-created `CartLineItem` — it has no order-level totals, taxes, or + * discounts. The route discards that response, re-fetches the full cart + * via `orderById`, and returns it under the same `cart` key as + * GET /api/commerce/cart/:id so clients only ever read one shape. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type AddLineItemBySkuIdResult, + type AddLineItemBySkuIdVariables, + type AddToCartItemInput, + buildAddLineItemBySkuIdInput, + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, +} from '@/lib/commerce/order-subgraph'; + +const addLineItemBySkuIdMutation = ` + mutation AddLineItemBySkuId($input: AddLineItemInput!) { + addLineItemBySkuId(input: $input) { + id + } + } +`; + +export default async function handler(req: Request, res: Response): Promise { + try { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + res.status(400).json({ error: 'Missing cart id' }); + return; + } + + const body = (req.body ?? {}) as Partial; + if (!body.skuId || !body.name || typeof body.quantity !== 'number') { + res.status(400).json({ error: 'Missing required fields: skuId, name, quantity' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); + + // Mutation only returns the new CartLineItem (no order totals). Discard + // it and re-fetch the full cart so the response matches the shape of + // GET /api/commerce/cart/:id. + await gqlRequest({ + endpoint, + query: addLineItemBySkuIdMutation, + variables: { + input: buildAddLineItemBySkuIdInput(cartId, { + skuId: body.skuId, + name: body.name, + quantity: body.quantity, + }), + }, + headers, + }); + + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); + + res.status(201).json({ cart: hydrated.orderById ?? null }); + } catch (error) { + res.status(500).json({ + error: 'Failed to add line item', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts new file mode 100644 index 00000000..8ea0adb6 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts @@ -0,0 +1,68 @@ +/** + * DELETE /api/commerce/cart/:id/items/:itemId + * + * Remove a line item from the cart — wired to the trash icon in the + * cart drawer. + * + * Response: { cart: CartOrder | null } + * The underlying `deleteLineItemById` GraphQL mutation only returns a + * boolean. The route discards that response, re-fetches the full cart + * via `orderById`, and returns it under the same `cart` key as + * GET /api/commerce/cart/:id so clients only ever read one shape. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type DeleteLineItemByIdResult, + type DeleteLineItemByIdVariables, + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, +} from '@/lib/commerce/order-subgraph'; + +const deleteLineItemByIdMutation = ` + mutation DeleteLineItemById($id: ID!, $orderId: ID!) { + deleteLineItemById(id: $id, orderId: $orderId) + } +`; + +export default async function handler(req: Request, res: Response): Promise { + try { + const cartId: unknown = req.params.id; + const itemId: unknown = req.params.itemId; + if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { + res.status(400).json({ error: 'Missing cart id or item id' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); + + await gqlRequest({ + endpoint, + query: deleteLineItemByIdMutation, + variables: { id: itemId, orderId: cartId }, + headers, + }); + + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); + + res.json({ cart: hydrated.orderById ?? null }); + } catch (error) { + res.status(500).json({ + error: 'Failed to delete line item', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts new file mode 100644 index 00000000..e77360b6 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts @@ -0,0 +1,88 @@ +/** + * PATCH /api/commerce/cart/:id/items/:itemId + * + * Update an existing cart line item — typically the quantity stepper in the + * cart drawer. + * + * Body: { quantity?: number, name?: string, fulfillmentMode?, status?, type?, details? } + * + * Response: { cart: CartOrder | null } + * The underlying `updateLineItemById` GraphQL mutation only returns the + * updated `CartLineItem` — it has no order-level totals. The route + * discards that response, re-fetches the full cart via `orderById`, and + * returns it under the same `cart` key as GET /api/commerce/cart/:id so + * clients only ever read one shape. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { + type GetCartOrderResult, + type GetCartOrderVariables, + getCartOrderQuery, + orderStorefrontEndpoint, + type UpdateLineItemByIdInput, + type UpdateLineItemByIdResult, + type UpdateLineItemByIdVariables, +} from '@/lib/commerce/order-subgraph'; + +const updateLineItemByIdMutation = ` + mutation UpdateLineItemById($input: UpdateLineItemByIdInput!) { + updateLineItemById(input: $input) { + id + } + } +`; + +type UpdateLineItemBody = Omit; + +export default async function handler(req: Request, res: Response): Promise { + try { + const cartId: unknown = req.params.id; + const itemId: unknown = req.params.itemId; + if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { + res.status(400).json({ error: 'Missing cart id or item id' }); + return; + } + + const body = (req.body ?? {}) as UpdateLineItemBody; + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); + + await gqlRequest({ + endpoint, + query: updateLineItemByIdMutation, + variables: { + input: { + id: itemId, + orderId: cartId, + name: body.name, + quantity: body.quantity, + fulfillmentMode: body.fulfillmentMode, + status: body.status, + type: body.type, + details: body.details, + }, + }, + headers, + }); + + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); + + res.json({ cart: hydrated.orderById ?? null }); + } catch (error) { + res.status(500).json({ + error: 'Failed to update line item', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts new file mode 100644 index 00000000..42dd56c0 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts @@ -0,0 +1,112 @@ +/** + * POST /api/commerce/checkout + * + * Thin HTTP wrapper around `createCheckoutSession()` in + * `lib/commerce/create-checkout-session.ts`. The browser MUST go through this + * route — never call the checkout subgraph directly. Other server-side code in + * the same app (e.g. an appointment-booking handler that needs to charge a + * deposit) should NOT loopback-fetch this endpoint; import + * `createCheckoutSession` from the lib module and call it in-process so the + * inbound request's cookies/auth context aren't stripped. + * + * Body: + * draftOrderId? - existing cart/draft order to convert to a checkout session + * skuId? - Buy Now path; checkout is built from a single line item + * quantity? - positive integer quantity for Buy Now (defaults to 1) + * returnUrl - where the hosted checkout sends the shopper on cancel/back + * successUrl - where the hosted checkout sends the shopper after payment + * + * Non-catalog prices are accepted only by the trusted server helper, never this route. + * Return destinations must match the host-configured checkoutReturnUrls policy. + * + * The caller is responsible for embedding `draftOrderId` (or any business + * order id) into `successUrl` before posting, e.g. + * `${origin}/checkout/success?orderId=${draftOrderId}` + * GoDaddy's hosted checkout does NOT append the order id to the redirect, so + * the success page has no way to know which order completed unless it is in + * the URL the caller supplies here. + * + * `storeId` and `channelId` are read server-side from `readCommerceConfig()` + * and `sourceApp` is set by the server checkout helper — never from the request + * body, so the client cannot choose which store/channel/source to charge. + * + * Response: { url, id, draftOrderId, storeId, channelId, businessId, + * storeName, sourceApp } from the created checkout session. The route returns + * 500 if checkout-api does not preserve the configured store/channel binding. + * Browser callers should redirect to `response.url`; this route does not + * return a `redirectUrl` field. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import type { CheckoutReturnUrlValidator } from '@/lib/commerce/checkout-return-urls'; +import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; + +import { + type CreateCheckoutSessionParams, + createCheckoutSession, +} from '@/lib/commerce/create-checkout-session'; + +type CheckoutBody = Partial; + +export default async function handler(req: Request, res: Response): Promise { + try { + const configuration = commerceConfigurationForResponse(res); + const body = (req.body ?? {}) as CheckoutBody; + const { draftOrderId, skuId, quantity, lineItemData, returnUrl, successUrl } = body; + + if (lineItemData !== undefined) { + res.status(400).json({ error: 'Non-catalog checkout must be created by the server.' }); + return; + } + + const checkoutSourceCount = [draftOrderId, skuId].filter(Boolean).length; + if ( + checkoutSourceCount !== 1 || + [draftOrderId, skuId].some( + (value) => value !== undefined && (typeof value !== 'string' || !value.trim()), + ) + ) { + res.status(400).json({ error: 'exactly one non-empty draftOrderId or skuId is required' }); + return; + } + + if (quantity !== undefined && (!Number.isSafeInteger(quantity) || quantity < 1)) { + res.status(400).json({ error: 'quantity must be a positive whole number' }); + return; + } + + if (req.headers?.['x-commerce-scope'] !== undefined) { + const config: CommerceConfig = configuration.read(); + if (!validateCommerceCartScope(req, res, config)) return; + } + + const validateReturnUrls: CheckoutReturnUrlValidator | undefined = + res.locals.commerceCheckoutReturnUrlValidator; + if (!validateReturnUrls) { + res.status(503).json({ error: 'Checkout return destinations are not configured.' }); + return; + } + const destinations = validateReturnUrls(returnUrl, successUrl); + if (!destinations) { + res.status(400).json({ error: 'Checkout return destinations are not allowed.' }); + return; + } + + const session = await createCheckoutSession( + { + draftOrderId, + skuId, + quantity, + ...destinations, + }, + configuration, + ); + + res.status(200).json(session); + } catch (error) { + res.status(500).json({ + error: 'Failed to create checkout session', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/config/GET.ts b/packages/commerce-server/src/server/api/commerce/config/GET.ts new file mode 100644 index 00000000..7deb364a --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/config/GET.ts @@ -0,0 +1,21 @@ +/** + * GET /api/commerce/config + * Public { cartScope, currencyCode } for the shared CommerceProvider. + * Store/channel IDs and credentials stay server-side. Do not cache across bindings. + */ +import type { Request, Response } from 'express'; +import { getCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; + +export default async function handler(_req: Request, res: Response): Promise { + res.setHeader('Cache-Control', 'no-store'); + try { + const config: CommerceConfig = readCommerceConfigForResponse(res); + res.json({ cartScope: getCommerceCartScope(config), currencyCode: config.currencyCode }); + } catch (cause: unknown) { + res.status(503).json({ + error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', + message: cause instanceof Error ? cause.message : 'Invalid Commerce configuration.', + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/order-status/GET.ts b/packages/commerce-server/src/server/api/commerce/order-status/GET.ts new file mode 100644 index 00000000..797f5404 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/order-status/GET.ts @@ -0,0 +1,40 @@ +/** + * GET /api/commerce/order-status?orderId=... + * + * Thin HTTP wrapper around `getOrderStatus()` in + * `lib/commerce/get-order-status.ts`. The browser may call this route to read + * order data after checkout. Other server-side code in the same app (e.g. + * an appointment-booking handler in a payment adapter) should NOT + * loopback-fetch this endpoint; import `getOrderStatus` from the lib module + * and call it in-process so the inbound request's auth context isn't stripped. + * + * Query: + * orderId - GoDaddy order id (required) + * + * Response: { success: true, order: CommerceOrderStatus } + * order.status is the payment status returned by the authorized Orders API. + * The host must authorize the caller's access to the requested order. + */ +import type { Request, Response } from 'express'; + +import { commerceConfigurationForResponse } from '@/lib/commerce/config'; +import { getOrderStatus } from '@/lib/commerce/get-order-status'; + +export default async function handler(req: Request, res: Response): Promise { + try { + const { orderId } = req.query; + if (!orderId || typeof orderId !== 'string') { + res.status(400).json({ success: false, error: 'missing or invalid orderId query parameter' }); + return; + } + + const order = await getOrderStatus(orderId, commerceConfigurationForResponse(res)); + res.status(200).json({ success: true, order }); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Failed to get order status', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/products/GET.ts b/packages/commerce-server/src/server/api/commerce/products/GET.ts new file mode 100644 index 00000000..aea3f87d --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -0,0 +1,154 @@ +/** + * GET /api/commerce/products + * + * Proxy for the catalog `skuGroups` query — paginated product grid backing + * the catalog page. Browser callers MUST go through this route; never call + * the catalog subgraph directly so the browser never sees `X-Store-ID` / + * `X-Client-ID` and the server can layer caching, abuse protection, or + * per-tenant filtering later without touching the client. + * + * Query params (all optional): + * first - page size (number) + * after - pagination cursor (string) + * searchQuery - filter by `label.contains` (ignored if productIds/categoryIds set) + * productIds - repeat to filter to a specific id set: ?productIds=a&productIds=b + * categoryIds - repeat to filter by category list ids: ?categoryIds=x + * + * Response: { skuGroups: Connection } — same shape as the GraphQL + * `data` field. Use the helpers in lib/commerce/catalog-subgraph.ts to extract view-model fields. + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { + buildSkuGroupsVariables, + catalogStorefrontEndpoint, + type SkuGroupsResult, + type SkuGroupsVariables, +} from '@/lib/commerce/catalog-subgraph'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; + +// Cards use group pricing/media and SKU identity/inventory for quick-add. +// Nested SKU price money fields would exceed the catalog API's depth limit of 10. +const skuGroupsQuery = ` + query SkuGroups($first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) { + skuGroups(first: $first, after: $after, id: $id, listId: $listId, label: $label) { + edges { + cursor + node { + id + name + label + description + htmlDescription + type + priceRange { + min + max + } + compareAtPriceRange { + min + max + } + mediaObjects(first: 25) { + edges { + node { + url + type + } + } + } + attributes { + edges { + node { + id + name + label + description + htmlDescription + values(first: 50) { + edges { + node { + id + name + label + } + } + } + } + } + } + skus(first: 2) { + pageInfo { hasNextPage } + totalCount + edges { + node { + id + label + name + inventoryCounts { + edges { + node { + id + quantity + type + } + } + } + } + } + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +`; + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string'); + if (typeof value === 'string' && value.length > 0) return [value]; + return []; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +export default async function handler(req: Request, res: Response): Promise { + try { + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + + const variables = buildSkuGroupsVariables({ + first: asNumber(req.query.first) ?? 24, + after: typeof req.query.after === 'string' ? req.query.after : undefined, + searchQuery: typeof req.query.searchQuery === 'string' ? req.query.searchQuery : undefined, + productIds: asStringArray(req.query.productIds), + categoryIds: asStringArray(req.query.categoryIds), + }); + + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuGroupsQuery, + variables, + headers: storefrontHeaders({ storeId, clientId }), + }); + + res.json(data); + } catch (error) { + res.status(500).json({ + error: 'Failed to load products', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts new file mode 100644 index 00000000..1fb13b99 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts @@ -0,0 +1,153 @@ +/** + * GET /api/commerce/products/:id + * + * Proxy for the catalog `skuGroup` query — single-product (PDP) detail. + * Re-call this route after the user picks attribute values to narrow the + * SKU set (pass `?attributeValues=red&attributeValues=large`). Use the + * `getSingleMatchedSku` helper returns the selected SKU, including price, + * inventory, and media, without a separate SKU request. + * + * Query params: + * attributeValues - repeat for each chosen attribute value's `name` field + * (from getProductAttributes), never its `id`. + * skuGroupFirst - max SKUs to return before any attribute is picked (default 50) + * + * Response: { skuGroup: SKUGroup | null } + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { + buildSkuGroupVariables, + catalogStorefrontEndpoint, + type SkuGroupResult, + type SkuGroupVariables, +} from '@/lib/commerce/catalog-subgraph'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; + +const skuGroupQuery = ` + query SkuGroup($id: String!, $first: Int, $attributeValues: [String!] = []) { + skuGroup(id: $id) { + id + name + label + description + htmlDescription + type + priceRange { + min + max + } + compareAtPriceRange { + min + max + } + mediaObjects(first: 25) { + edges { + node { + url + type + } + } + } + attributes { + edges { + node { + id + name + label + description + htmlDescription + values(first: 50) { + edges { + node { + id + name + label + } + } + } + } + } + } + skus(attributeValues: { has: $attributeValues }, first: $first) { + pageInfo { hasNextPage } + totalCount + edges { + node { + id + label + name + description + prices(first: 10) { + edges { + node { + value { value currencyCode } + compareAtValue { value currencyCode } + } + } + } + mediaObjects(first: 25) { + edges { node { url type } } + } + inventoryCounts { + edges { + node { + id + quantity + type + } + } + } + } + } + } + } + } +`; + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string'); + if (typeof value === 'string' && value.length > 0) return [value]; + return []; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +export default async function handler(req: Request, res: Response): Promise { + try { + const productId: unknown = req.params.id; + if (typeof productId !== 'string' || !productId) { + res.status(400).json({ error: 'Missing product id' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + + const variables = buildSkuGroupVariables({ + productId, + selectedAttributeValues: asStringArray(req.query.attributeValues), + skuGroupFirst: asNumber(req.query.skuGroupFirst), + }); + + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuGroupQuery, + variables, + headers: storefrontHeaders({ storeId, clientId }), + }); + + res.json(data); + } catch (error) { + res.status(500).json({ + error: 'Failed to load product', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts new file mode 100644 index 00000000..f5373276 --- /dev/null +++ b/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts @@ -0,0 +1,103 @@ +/** + * GET /api/commerce/skus/:id + * + * Proxy for the catalog `sku` query — once the PDP has resolved a single + * SKU (single-variant SKUGroup, or attribute selection narrowed to one), + * fetch the full SKU for prices, media, inventory, and description. + * + * Response: { sku: SKU | null } + */ +import type { Request, Response } from 'express'; +import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { + catalogStorefrontEndpoint, + type SkuResult, + type SkuVariables, +} from '@/lib/commerce/catalog-subgraph'; +import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; + +const skuQuery = ` + query Sku($id: String!) { + sku(id: $id) { + id + label + name + description + htmlDescription + code + prices { + edges { + node { + id + value { + value + currencyCode + } + compareAtValue { + value + currencyCode + } + } + } + } + inventoryCounts { + edges { + node { + id + quantity + type + } + } + } + mediaObjects { + edges { + node { + id + url + type + label + position + } + } + } + attributeValues { + edges { + node { + id + name + label + } + } + } + } + } +`; + +export default async function handler(req: Request, res: Response): Promise { + try { + const skuId: unknown = req.params.id; + if (typeof skuId !== 'string' || !skuId) { + res.status(400).json({ error: 'Missing sku id' }); + return; + } + + const config: CommerceConfig = readCommerceConfigForResponse(res); + if (!validateCommerceCartScope(req, res, config)) return; + const { storeId, clientId, apiBaseUrl } = config; + + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuQuery, + variables: { id: skuId }, + headers: storefrontHeaders({ storeId, clientId }), + }); + + res.json(data); + } catch (error) { + res.status(500).json({ + error: 'Failed to load sku', + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/commerce-server/tsconfig.json b/packages/commerce-server/tsconfig.json new file mode 100644 index 00000000..ad559a88 --- /dev/null +++ b/packages/commerce-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "tsdown.config.ts", "vitest.config.ts"] +} diff --git a/packages/commerce-server/tsdown.config.ts b/packages/commerce-server/tsdown.config.ts new file mode 100644 index 00000000..c24218e1 --- /dev/null +++ b/packages/commerce-server/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + external: ['express'], +}); diff --git a/packages/commerce-server/vitest.config.ts b/packages/commerce-server/vitest.config.ts new file mode 100644 index 00000000..4ebba1ba --- /dev/null +++ b/packages/commerce-server/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/packages/commerce-storefront/LICENSE.md b/packages/commerce-storefront/LICENSE.md new file mode 100644 index 00000000..1cdf8134 --- /dev/null +++ b/packages/commerce-storefront/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 GoDaddy Operating Company, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md new file mode 100644 index 00000000..1156dfe9 --- /dev/null +++ b/packages/commerce-storefront/README.md @@ -0,0 +1,144 @@ +# Commerce storefront + +`@godaddy/commerce-storefront` provides complete React storefront templates: a catalog, product details with variant selection, a shared cart, and a cart drawer. Applications import compiled components instead of copying and maintaining their implementation. + +This is an opinionated package for React applications that use React Router 7 or 8.3+, TanStack Query 5, and the documented same-origin Commerce API. It works with the host application’s router and query provider and does not require Tailwind configuration. + +## Installation + +This package is not published yet. Use the local workspace example while reviewing this branch. After the first release, install it with: + +```sh +pnpm add @godaddy/commerce-storefront @tanstack/react-query react react-dom react-router +``` + +Import the stylesheet once. Mount `CommerceStorefront` once inside your application's existing router and query provider. It owns the commerce state and renders one cart drawer. Keep your header and page routes inside it so cart buttons share that state. + +#### Example application + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import { + Catalog, CartButton, CommerceStorefront, ProductDetails, +} from '@godaddy/commerce-storefront'; +import '@godaddy/commerce-storefront/styles.css'; + +const client = new QueryClient(); + +export function App() { + return ( + + + +
My store
+
+ + } /> + } /> + +
+
+
+
+ ); +} +``` + +Use the providers your application already has; do not create another router or query client for this package. Your server must implement the [server API contract](docs/server-api.md) before these components can load products. Express hosts can mount `@godaddy/commerce-server`; custom servers can implement the contract directly. The host owns store credentials, merchant provisioning, payment readiness, and runtime configuration. + +## Configuration + +| Option | Default | Meaning | +| --- | --- | --- | +| `catalogPath` | `/shop` | Same-origin catalog route | +| `productPath` | `/products` | Same-origin prefix for product links; register `${productPath}/:productId` | +| `checkoutSuccessPath` | unset | Same-origin return route; enables checkout UI only when supplied | +| `theme` | neutral palette | CSS custom properties applied to storefront surfaces and the portalled drawer | + +Provide root-relative paths without a trailing slash. Enable `checkoutSuccessPath` only after your server supports checkout and validates merchant readiness. Mount a corresponding return page. A redirect back from checkout is **not proof of payment**; that page must obtain authoritative payment status from your server. This package does not provide a payment receipt page or merchant onboarding. + +`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store/channel binding changes. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. + +A connection failure leaves the surrounding application and its state mounted. Catalog and product surfaces show the connection error and retry action. Custom integrations can render `CommerceStatus` or inspect `useCommerce().connection`. + +#### Example appearance and copy + +```tsx + + {/* Register /collection and /item/:productId in your router. */} + + +``` + +The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API. + +The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization and arbitrary component slots are outside this initial API. + +## Compatibility + +The package supports React Router 7 and React Router 8.3 or later in the 8.x series. The host still owns the router and query client; no integration API changes are needed between these versions. + +The repository example uses React Router 7. Follow each router version's own React and browser requirements. + +## Components and hooks + +| Export | Purpose | +| --- | --- | +| `CommerceStorefront` | Recommended integration: provider and one drawer | +| `Catalog` | Six products per cursor page, with `title`, `description`, and `showHeader` props | +| `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results | +| `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link | +| `CartButton` | Opens the shared drawer and displays item count | +| `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity` | +| `CommerceStatus` | Connection progress/error/retry for custom layouts | +| `useCommerce` | Cart, connection, pending/error state and serialized cart actions | +| `CommerceProvider`, `CartDrawer` | Lower-level composition when the recommended wrapper does not fit; mount each once | + +The package exports TypeScript catalog/cart response types and selection/summary helpers for custom product layouts. `useCommerce` actions return `Promise`: `false` means an operation failed or its connection became stale. Inspect `error` for active-session failures. Do not automatically retry a failed mutation: the server may have committed it before the response failed. `applyDiscount(code)` is available to custom layouts; the default drawer does not render a promotion form. + +`Catalog` renders its `title` as an H1 by default. When the host page owns its semantic heading, render that page H1 and pass `showHeader={false}` so the document still has exactly one H1. + +## How it works + +Products are SKU groups. The package purchases only an unambiguous SKU, verifies all selected attributes through the server, uses SKU prices/images, and distinguishes untracked inventory from sold-out inventory. Server-side inventory and pricing checks remain mandatory. + +Cart mutations share one queue, including initial cart creation. The cart ID is saved under `godaddy:commerce-storefront:cart:`. Browser Web Locks coordinate tabs where supported; storage/focus events refresh the cart. If storage fails, the current page keeps the cart ID in memory and shows a warning. Browsers without Web Locks have only per-provider serialization; the server must handle concurrent writes correctly. + +Changing the server-provided cart scope resets cart state and prevents old responses from affecting the new connection. A failed configuration refresh temporarily disables commerce and also invalidates pending responses. Normal configuration loading and failures do not unmount host content. + +## Guides + +- [Server API contract](docs/server-api.md) +- [Independent Vite consumer](../../examples/commerce-storefront/README.md) + +## Commands + +From the repository root, with Node 24: + +```sh +pnpm install +pnpm --filter @godaddy/commerce-storefront build +pnpm --filter @godaddy/commerce-storefront typecheck +pnpm --filter @godaddy/commerce-storefront lint +pnpm --filter @godaddy/commerce-storefront test +pnpm --filter commerce-storefront-example build +pnpm --filter commerce-storefront-example dev +``` + +Build before testing: artifact tests check the compiled JavaScript and shipped CSS as well as source behavior. The build uses local locked tool versions. Nothing in these commands publishes the package. + +## License + +[MIT](LICENSE.md). diff --git a/packages/commerce-storefront/biome.json b/packages/commerce-storefront/biome.json new file mode 100644 index 00000000..223d4626 --- /dev/null +++ b/packages/commerce-storefront/biome.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 110 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "single" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "useSemanticElements": "off" + } + } + }, + "files": { + "includes": [ + "**/*", + "!!src/styles.css" + ] + } +} diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md new file mode 100644 index 00000000..9b73a489 --- /dev/null +++ b/packages/commerce-storefront/docs/server-api.md @@ -0,0 +1,101 @@ +# Server API contract + +The browser calls JSON endpoints under `/api/commerce` on its own origin. The consuming application provides these routes. This browser package contains no server credentials, platform configuration reader, merchant provisioning, or GraphQL transport. Express applications can use the companion `@godaddy/commerce-server` package; other hosts can implement this contract directly. + +Server implementations can use GoDaddy Commerce APIs or their existing integration, but must return these shapes and enforce the same purchase rules. Returning a similar-looking GraphQL mutation result is insufficient: cart mutations return a complete refreshed cart. + +## Requests and failures + +- Requests use same-origin browser credentials. JSON writes set `Content-Type: application/json`. +- Cart, catalog and checkout requests include `X-Commerce-Scope` from `/config`. Validate that scope against the server's current store/channel binding, especially before writes. Treat it as a stale-binding guard, not authorization. Authenticate and authorize requests independently. +- Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically. +- Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. +- Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds. +- Resolve prices, taxes, discounts and availability on the server. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. + +## Endpoints + +| Method and path | Input | Successful JSON response | +| --- | --- | --- | +| `GET /config` | None | `{ cartScope: string, currencyCode: string }` | +| `GET /products?first=6&after=` | Optional opaque cursor | `{ skuGroups: Connection }` | +| `GET /products/:id` | URI-encoded product ID | `{ skuGroup: SKUGroup \| null }` | +| `GET /products/:id?attributeValues=blue&attributeValues=large` | Repeated selected attribute **names**, not IDs | `{ skuGroup: SKUGroup \| null }` with matching SKU connection | +| `GET /cart/:id` | URI-encoded cart ID | `{ cart: CartOrder \| null }` | +| `POST /cart` | `{ lineItems: [{ skuId, name, quantity }] }` | `{ cart: CartOrder }` | +| `POST /cart/:id/items` | `{ skuId, name, quantity }` | `{ cart: CartOrder }` | +| `PATCH /cart/:id/items/:itemId` | `{ quantity }` | `{ cart: CartOrder }` | +| `DELETE /cart/:id/items/:itemId` | No body | `{ cart: CartOrder }` | +| `POST /cart/:id/discounts` | `{ discountCodes: string[] }` | `{ cart: CartOrder }` | +| `POST /checkout` | `{ draftOrderId, returnUrl, successUrl }` | `{ url: string }` | + +`/checkout` is required only when checkout is enabled. `/discounts` is needed if the host uses `applyDiscount`. The standard catalog/detail/cart flow uses the other routes. All product/cart IDs in request paths are encoded. + +## Configuration + +`cartScope` is a nonempty opaque identifier for the effective store/channel binding. It is not a secret. Rotate it when the binding changes so a saved cart cannot cross stores. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. + +The browser rechecks configuration on window focus when stale. Return current server configuration rather than a browser-selected store. Persisted IDs use the package-specific key documented in the README; migration from another application's storage keys belongs to that application's integration. + +## Product responses + +Exported `SKUGroup`, `SKU`, `Connection`, `SkuGroupsResult` and `SkuGroupResult` describe the public response types. Connections use `{ edges: [{ node }], totalCount?, pageInfo? }`. + +#### Example simple product + +```json +{ + "skuGroup": { + "id": "mug", + "label": "Studio mug", + "description": "A ceramic mug.", + "priceRange": { "min": 2400, "max": 2400 }, + "mediaObjects": { "edges": [{ "node": { "type": "IMAGE", "url": "/mug.jpg" } }] }, + "skus": { + "totalCount": 1, + "pageInfo": { "hasNextPage": false }, + "edges": [{ "node": { + "id": "mug-blue", + "prices": { "edges": [{ "node": { "value": { "value": 2400, "currencyCode": "USD" } } }] }, + "inventoryCounts": { "edges": [{ "node": { "type": "AVAILABLE", "quantity": 8 } }] } + } }] + } + } +} +``` + +For options, return `attributes.edges[].node` with `name`, `label`, and `values.edges[].node` containing `name` and `label`. For example, the attribute `color` has values named `blue` and `clay`. The browser sends those value names as repeated `attributeValues` parameters. Return the group with a SKU connection filtered to that exact combination. Zero or multiple results cannot be purchased. Include accurate `totalCount` and `hasNextPage`; a truncated one-item result must never look like a complete match. + +Products without attribute definitions can offer explicit SKU selection only when all SKUs are returned with unique IDs and unique nonempty labels/names. Ambiguous or incomplete collections cannot be purchased. No inventory records means untracked inventory; records without an `AVAILABLE` quantity mean unavailable. Supply appropriate records for physical inventory. + +## Cart responses + +A cart is a draft order. Return its `id`, complete `lineItems`, and authoritative `totals` after every mutation. Cart line items have their own `id`, separate from `skuId`. A new-cart/add response must include at least one line item. Update/delete/discount responses must retain the cart ID, even when deletion leaves the cart empty. + +#### Example cart + +```json +{ + "cart": { + "id": "draft-123", + "lineItems": [{ + "id": "line-456", "skuId": "mug-blue", "name": "Studio mug", "quantity": 2, + "totals": { "subTotal": { "value": 4800, "currencyCode": "USD" } } + }], + "totals": { + "subTotal": { "value": 4800, "currencyCode": "USD" }, + "total": { "value": 4800, "currencyCode": "USD" } + } + } +} +``` + +Optional totals include `shippingTotal`, `taxTotal`, and `discountTotal`. Optional line details include `productAssetUrl` and `selectedOptions: [{ attribute, values: string[] }]`. The drawer shows supplied totals; it does not calculate authoritative prices from catalog values. A missing total disables checkout. + +## Checkout boundary + +The client refreshes the cart before checkout and sends its ID, an absolute catalog `returnUrl`, and an absolute `successUrl` with `orderId` appended. The server must verify the cart's binding, contents, merchant readiness, and allowed return origins/paths before creating a session. Return an HTTPS checkout URL. The client rejects missing or non-HTTPS URLs and performs a browser navigation to the returned URL. + +When using `@godaddy/commerce-server`, configure the router's `checkoutReturnUrls.returnUrls` with the absolute catalog return URL and `checkoutReturnUrls.successUrls` with the absolute success-page URL. The package matches these exact destinations and permits an additional `orderId` parameter on success URLs. Missing policy disables HTTP checkout. Public HTTP requests accept only cart or SKU checkout; non-catalog amounts belong in a trusted server handler. + +Do not treat `checkoutSuccessPath`, a client-provided URL, the redirect itself, or a draft-order response as proof that payment succeeded. Verify payment using the checkout/payment service or trusted webhook state. Expire or reject paid/closed drafts on subsequent cart reads so returning customers cannot reuse a completed cart. diff --git a/packages/commerce-storefront/package.json b/packages/commerce-storefront/package.json new file mode 100644 index 00000000..a6632d51 --- /dev/null +++ b/packages/commerce-storefront/package.json @@ -0,0 +1,73 @@ +{ + "name": "@godaddy/commerce-storefront", + "version": "0.0.0", + "description": "Opinionated React storefront templates for GoDaddy Commerce", + "type": "module", + "license": "MIT", + "author": "GoDaddy.com Operating Company, LLC", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "docs", + "LICENSE.md" + ], + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./styles.css": "./dist/styles.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown && node scripts/build-css.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "biome check src", + "lint:fix": "biome check --write src", + "prepublishOnly": "pnpm build" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.0.0", + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0", + "react-router": "^7.0.0 || ^8.3.0" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.2", + "@tailwindcss/cli": "^4.1.10", + "@tanstack/react-query": "^5.66.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.13.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "jsdom": "^26.0.0", + "postcss": "^8.5.3", + "react": "^19", + "react-dom": "^19", + "react-router": "^7.0.0", + "tailwindcss": "^4.1.4", + "tailwindcss-v3": "npm:tailwindcss@^3.4.19", + "tsdown": "^0.15.6", + "typescript": "~5.7.3", + "vitest": "5.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/godaddy/javascript.git", + "directory": "packages/commerce-storefront" + } +} diff --git a/packages/commerce-storefront/scripts/build-css.mjs b/packages/commerce-storefront/scripts/build-css.mjs new file mode 100644 index 00000000..4641eca2 --- /dev/null +++ b/packages/commerce-storefront/scripts/build-css.mjs @@ -0,0 +1,42 @@ +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile, unlink } from 'node:fs/promises'; +import postcss from 'postcss'; +import { fileURLToPath } from 'node:url'; + +// Resolve the v4 CLI explicitly: the v3 compatibility-test dependency has the same binary name. +const cliPackageUrl = import.meta.resolve('@tailwindcss/cli/package.json'); +const cliPackage = JSON.parse(await readFile(new URL(cliPackageUrl), 'utf8')); +const cliPath = fileURLToPath(new URL(cliPackage.bin.tailwindcss, cliPackageUrl)); +execFileSync(process.execPath, [cliPath, '-i', 'src/styles.css', '-o', 'dist/raw.css'], { stdio: 'inherit' }); +const css = postcss.parse(await readFile('dist/raw.css', 'utf8')); +css.walkRules((rule) => { + // Nested selectors inherit their parent's scope. Keyframes are identifiers, not document selectors. + let parent = rule.parent; + while (parent) { + if (parent.type === 'rule' || (parent.type === 'atrule' && /keyframes$/.test(parent.name))) return; + parent = parent.parent; + } + rule.selectors = rule.selectors.map((selector) => { + if (selector.startsWith('.commerce-')) return selector; + if (selector === ':root' || selector === ':host') return '.commerce-storefront'; + return `.commerce-storefront ${selector}`; + }); +}); +// Tailwind v3 interprets dependency @layer blocks as source directives. Publish +// ordinary CSS, ordered by the declared cascade layers (not their source order). +// In particular, base resets must precede utilities even when emitted later. +const layerRules = css.nodes.filter((node) => node.type === 'atrule' && node.name === 'layer'); +const layers = new Map(); +for (const rule of layerRules) { + for (const name of rule.params.split(',').map((name) => name.trim())) { + if (!layers.has(name)) layers.set(name, []); + } + if (rule.nodes) layers.get(rule.params).push(...rule.nodes); +} +layerRules[0]?.before([...layers.values()].flat()); +for (const rule of layerRules) rule.remove(); +css.walkAtRules('layer', () => { + throw new Error('Unexpected nested CSS layer in compiled storefront styles'); +}); +await writeFile('dist/styles.css', css.toString()); +await unlink('dist/raw.css'); diff --git a/packages/commerce-storefront/src/api.ts b/packages/commerce-storefront/src/api.ts new file mode 100644 index 00000000..71e029f9 --- /dev/null +++ b/packages/commerce-storefront/src/api.ts @@ -0,0 +1,101 @@ +export interface StorefrontConfig { + cartScope: string; + currencyCode: string; + catalogPath: string; + productPath: string; + checkoutSuccessPath?: string; +} + +export class CartIdStorage { + private currentId: string | null = null; + private pendingWrite: boolean = false; + + constructor(private readonly key: string) {} + + read(): string | null { + if (this.pendingWrite) return this.currentId; + return localStorage.getItem(this.key); + } + + write(id: string | null): void { + this.currentId = id; + this.pendingWrite = true; + if (id) localStorage.setItem(this.key, id); + else localStorage.removeItem(this.key); + this.pendingWrite = false; + } +} + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export const checkedFetch: typeof globalThis.fetch = async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + const response: Response = await fetch(input, init); + if (!response.ok) { + const data: string = await response.text(); + const parsed: unknown = ((): unknown => { + try { + return JSON.parse(data) as unknown; + } catch (cause: unknown) { + throw new ApiError(`Commerce request failed (${response.status}). Please retry.`, response.status, { + cause, + }); + } + })(); + const detail: string = + typeof parsed === 'object' && parsed !== null && 'error' in parsed && typeof parsed.error === 'string' + ? parsed.error + : 'Commerce request failed. Please retry.'; + throw new ApiError(detail, response.status); + } + return response; +}; + +export async function request(path: string, init?: RequestInit): Promise { + const headers: Headers = new Headers(init?.headers); + if (init?.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + const timeout: AbortSignal | undefined = + !init?.method || init.method.toUpperCase() === 'GET' ? AbortSignal.timeout(15_000) : undefined; + const signal: AbortSignal | null | undefined = timeout + ? AbortSignal.any([timeout, ...(init?.signal ? [init.signal] : [])]) + : init?.signal; + try { + const response: Response = await checkedFetch(`/api/commerce${path}`, { + ...init, + signal, + headers, + cache: 'no-store', + }); + return (await response.json()) as T; + } catch (cause: unknown) { + if (timeout?.aborted && !init?.signal?.aborted) { + throw new ApiError('Commerce information took too long to load. Please retry.', 408, { + cause, + }); + } + throw cause; + } +} + +export function money(value: number, currency: string): string { + const formatter: Intl.NumberFormat = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }); + const digits: number = formatter.resolvedOptions().maximumFractionDigits ?? 2; + return formatter.format(value / 10 ** digits); +} + +export function message(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong. Please retry.'; +} diff --git a/packages/commerce-storefront/src/artifacts.test.ts b/packages/commerce-storefront/src/artifacts.test.ts new file mode 100644 index 00000000..59a5296b --- /dev/null +++ b/packages/commerce-storefront/src/artifacts.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { readFile } from 'node:fs/promises'; +import postcss, { type Container, type Document } from 'postcss'; +import tailwindV3 from 'tailwindcss-v3'; +import { expect, it } from 'vitest'; + +it('ships CSS that scopes every document selector to a commerce surface', async () => { + const css = postcss.parse(await readFile(new URL('../dist/styles.css', import.meta.url), 'utf8')); + const selectors: string[] = []; + css.walkRules((rule) => { + let parent: Container | Document | undefined = rule.parent; + while (parent) { + if ( + parent.type === 'rule' || + (parent.type === 'atrule' && 'name' in parent && /keyframes$/.test(String(parent.name))) + ) + return; + parent = parent.parent; + } + selectors.push(...rule.selectors); + }); + expect(selectors.length).toBeGreaterThan(100); + expect(selectors.filter((selector) => !selector.startsWith('.commerce-'))).toEqual([]); + expect(selectors).toContain('.commerce-storefront .min-h-11'); + expect(selectors).toContain('.commerce-storefront .grid-cols-1'); + expect(css.toString()).toContain('var(--commerce-accent, #171717)'); +}); + +it('ships a client package with framework peers external and no server dependencies', async () => { + const js = await readFile(new URL('../dist/index.js', import.meta.url), 'utf8'); + expect(js).not.toMatch(/node:(?:fs|crypto)|GODADDY_OAUTH_CLIENT_SECRET|@godaddy\/commerce-server/); + expect(js).toContain('react/jsx-runtime'); + expect(js).toContain('from "react"'); + expect(js).toContain('from "@tanstack/react-query"'); + const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + expect(pkg.files).not.toContain('src'); + expect(pkg.sideEffects).toContain('**/*.css'); + expect(pkg.exports['./styles.css']).toBe('./dist/styles.css'); +}); + +// Vite processes dependency CSS separately through the host's PostCSS plugins. +// No @tailwind directives or scanned package classes should be needed in the host. +it('passes shipped CSS through Tailwind v3 without losing package styles', async () => { + const css = await readFile(new URL('../dist/styles.css', import.meta.url), 'utf8'); + const result = await postcss([tailwindV3({ content: [{ raw: '
Host application
' }] })]).process( + css, + { + from: 'node_modules/@godaddy/commerce-storefront/dist/styles.css', + }, + ); + expect(result.css).not.toMatch(/@(?:layer|tailwind|apply|theme|source)\b/); + expect(result.css).toContain('.commerce-storefront .grid-cols-1'); + expect(result.css).toContain('var(--commerce-accent, #171717)'); + expect(result.css).toContain('@media'); + expect(result.css).toContain('@keyframes pulse'); + const resetIndex = css.search(/\.commerce-storefront :where\(button,\s*input,\s*select\)/); + expect(resetIndex).toBeGreaterThanOrEqual(0); + expect(resetIndex).toBeLessThan(css.indexOf('.commerce-storefront .grid-cols-1')); + const rules = (value: string): string[] => { + const declarations: string[] = []; + postcss.parse(value).walkDecls((declaration) => { + declarations.push(declaration.toString()); + }); + return declarations; + }; + expect(rules(result.css)).toEqual(rules(css)); +}); diff --git a/packages/commerce-storefront/src/cart-model.ts b/packages/commerce-storefront/src/cart-model.ts new file mode 100644 index 00000000..ecd4d758 --- /dev/null +++ b/packages/commerce-storefront/src/cart-model.ts @@ -0,0 +1,176 @@ +export interface Money { + value?: number | null; + currencyCode?: string | null; +} + +export interface DraftOrderContext { + storeId?: string | null; + channelId?: string | null; + owner?: string | null; +} + +export interface OrderTotals { + subTotal?: Money | null; + shippingTotal?: Money | null; + taxTotal?: Money | null; + discountTotal?: Money | null; + productDiscountTotal?: Money | null; + shippingDiscountTotal?: Money | null; + feeTotal?: Money | null; + total?: Money | null; +} + +export interface LineItemTotals { + subTotal?: Money | null; + taxTotal?: Money | null; + discountTotal?: Money | null; + feeTotal?: Money | null; +} + +export interface CartSelectedOption { + attribute?: string | null; + values?: string[] | null; +} + +export interface CartSelectedAddonValue { + name?: string | null; + costAdjustment?: Money | null; +} + +export interface CartSelectedAddon { + attribute?: string | null; + sku?: string | null; + values?: CartSelectedAddonValue[] | null; +} + +export interface CartLineItemDetails { + productAssetUrl?: string | null; + sku?: string | null; + unitOfMeasure?: string | null; + selectedOptions?: CartSelectedOption[] | null; + selectedAddons?: CartSelectedAddon[] | null; +} + +export interface CartDiscount { + id?: string | null; + name?: string | null; + code?: string | null; + amount?: Money | null; + ratePercentage?: string | null; + appliedBeforeTax?: boolean | null; +} + +export interface CartTax { + id?: string | null; + name?: string | null; + amount?: Money | null; + ratePercentage?: string | null; + included?: boolean | null; + exempted?: boolean | null; +} + +export interface CartNote { + id?: string | null; + content?: string | null; + author?: string | null; + authorType?: string | null; + createdAt?: string | null; +} + +export interface CartLineItem { + id?: string | null; + name?: string | null; + quantity?: number | null; + skuId?: string | null; + type?: string | null; + fulfillmentMode?: string | null; + details?: CartLineItemDetails | null; + totals?: LineItemTotals | null; + discounts?: CartDiscount[] | null; + taxes?: CartTax[] | null; + notes?: CartNote[] | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface CartAddress { + addressLine1?: string | null; + addressLine2?: string | null; + addressLine3?: string | null; + adminArea1?: string | null; + adminArea2?: string | null; + adminArea3?: string | null; + adminArea4?: string | null; + postalCode?: string | null; + countryCode?: string | null; +} + +export interface CartShippingInfo { + firstName?: string | null; + lastName?: string | null; + email?: string | null; + phone?: string | null; + companyName?: string | null; + address?: CartAddress | null; +} + +export interface CartOrder { + id?: string | null; + customerId?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + context?: DraftOrderContext | null; + lineItems?: CartLineItem[] | null; + totals?: OrderTotals | null; + discounts?: CartDiscount[] | null; + taxes?: CartTax[] | null; + shipping?: CartShippingInfo | null; + notes?: CartNote[] | null; + tags?: string[] | null; +} + +export interface AddToCartItemInput { + skuId: string; + name: string; + quantity: number; +} + +export interface CartSummaryTotals { + itemCount: number; + currencyCode: string; + subtotal: number; + shipping: number; + taxes: number; + discount: number; + total: number; +} + +export async function addToCart( + cartId: string | null, + item: AddToCartItemInput, + fetchFn: typeof globalThis.fetch = globalThis.fetch, +): Promise<{ cart: CartOrder | null }> { + const url = cartId ? `/api/commerce/cart/${encodeURIComponent(cartId)}/items` : '/api/commerce/cart'; + const body = cartId ? item : { lineItems: [item] }; + const res = await fetchFn(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json() as Promise<{ cart: CartOrder | null }>; +} + +export function getCartSummaryTotals(order: CartOrder | null | undefined): CartSummaryTotals { + const lineItems = order?.lineItems ?? []; + const currencyCode = order?.totals?.total?.currencyCode || 'USD'; + + return { + itemCount: lineItems.reduce((sum, item) => sum + (item.quantity || 0), 0), + currencyCode, + subtotal: order?.totals?.subTotal?.value || 0, + shipping: order?.totals?.shippingTotal?.value || 0, + taxes: order?.totals?.taxTotal?.value || 0, + discount: order?.totals?.discountTotal?.value || 0, + total: order?.totals?.total?.value || 0, + }; +} diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx new file mode 100644 index 00000000..592557de --- /dev/null +++ b/packages/commerce-storefront/src/cart.tsx @@ -0,0 +1,312 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { type ReactElement, useState } from 'react'; +import { Link } from 'react-router'; +import { money } from './api'; +import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; +import type { SKU } from './catalog-model'; +import { getAvailableInventoryQuantity } from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { StorefrontSurface } from './storefront-surface'; + +export const buttonClass: string = + 'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-commerce-accent px-5 py-2 text-sm font-semibold text-commerce-on-accent hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-700 disabled:hover:bg-neutral-200'; +export const inputClass: string = + 'min-h-11 rounded-lg border border-neutral-400 bg-white px-3 py-2 text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900'; + +export function CartButton(): ReactElement { + const { cart, setOpen, open, connection } = useCommerce(); + const count: number = cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0; + return ( + + + + ); +} + +export function AddToCartButton({ + sku, + name, + quantity = 1, +}: { + sku: SKU; + name: string; + quantity?: number; +}): ReactElement { + const { addItem, pending, hydrating, error, open, connection } = useCommerce(); + const [adding, setAdding] = useState(false); + const available: number | null = getAvailableInventoryQuantity(sku); + const disabled: boolean = + connection !== 'ready' || + !sku.id || + adding || + hydrating || + available === 0 || + !Number.isInteger(quantity) || + quantity < 1 || + (available !== null && quantity > available); + async function handleAdd(): Promise { + if (disabled || pending || !sku.id) return; + setAdding(true); + try { + await addItem({ skuId: sku.id, name, quantity }); + } finally { + setAdding(false); + } + } + return ( + + + {error && !open && ( +

+ {error} +

+ )} +
+ ); +} + +export function CartDrawer(): ReactElement { + const { + config, + cart, + open, + setOpen, + restoreFocus, + pending, + hydrating, + error, + storageWarning, + changeQuantity, + removeItem, + refresh, + checkout, + } = useCommerce(); + const [drawerAction, setDrawerAction] = useState(null); + const checkingOut: boolean = drawerAction === 'checkout'; + const locked: boolean = pending || hydrating || drawerAction !== null; + const items = cart?.lineItems ?? []; + const summary: CartSummaryTotals = getCartSummaryTotals(cart); + const currency: string = cart?.totals?.total?.currencyCode ?? config.currencyCode; + async function handleAction(action: string, operation: () => Promise): Promise { + if (locked) return; + setDrawerAction(action); + try { + await operation(); + } finally { + setDrawerAction(null); + } + } + return ( + + + + + { + event.preventDefault(); + restoreFocus(); + }} + > +
+
+ Your cart + + Review your items before checkout. + +
+ + ✕ + +
+
+ {storageWarning && ( +

+ {storageWarning} +

+ )} + {error && ( +
+

{error}

+ +
+ )} + {hydrating &&

Loading your cart…

} + {!hydrating && !items.length && ( +
+

Your cart is empty

+

Find something you love in the shop.

+ setOpen(false)} + > + Browse products + +
+ )} +
    + {items.map((item) => ( +
  • +
    + {item.details?.productAssetUrl && ( + + )} +
    +

    {item.name}

    + {item.details?.selectedOptions?.map((option) => ( +

    + {option.attribute}: {option.values?.join(', ')} +

    + ))} +

    + {typeof item.totals?.subTotal?.value === 'number' + ? money(item.totals.subTotal.value, item.totals.subTotal.currencyCode ?? currency) + : 'Price unavailable'} +

    +
    +
    +
    +
    + + + Quantity: + {item.quantity} + + +
    + +
    +
  • + ))} +
+
+ {items.length > 0 && ( +
+
+ {( + [ + ['Subtotal', cart?.totals?.subTotal, summary.subtotal], + ['Shipping', cart?.totals?.shippingTotal, summary.shipping], + ['Tax', cart?.totals?.taxTotal, summary.taxes], + ] as const + ).map( + ([label, amount, value]) => + typeof amount?.value === 'number' && ( +
+
{label}
+
{money(value, amount.currencyCode ?? currency)}
+
+ ), + )} +
+
Total
+
+ {typeof cart?.totals?.total?.value === 'number' + ? money(summary.total, currency) + : 'Unavailable'} +
+
+
+

Shipping and taxes may change at checkout.

+ {config.checkoutSuccessPath ? ( + + ) : ( +

Checkout is not available yet.

+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/packages/commerce-storefront/src/catalog-model.test.ts b/packages/commerce-storefront/src/catalog-model.test.ts new file mode 100644 index 00000000..9857c808 --- /dev/null +++ b/packages/commerce-storefront/src/catalog-model.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + getAvailableInventoryQuantity, + getLabeledSkuOptions, + getSingleMatchedSku, + type SKU, + type SKUGroup, +} from './catalog-model'; + +const sku = (id: string, label = id): SKU => ({ id, label }); +const group = (skus: SKU[], extra = {}): SKUGroup => ({ + skus: { edges: skus.map((node) => ({ node })), totalCount: skus.length, ...extra }, +}); +describe('safe SKU selection', () => { + it('only permits direct purchase for exactly one complete SKU result', () => { + expect(getSingleMatchedSku(group([sku('one')]))?.id).toBe('one'); + expect(getSingleMatchedSku(group([sku('one')], { totalCount: 2 }))).toBeNull(); + expect(getSingleMatchedSku(group([sku('one')], { pageInfo: { hasNextPage: true } }))).toBeNull(); + expect(getSingleMatchedSku(group([sku('one'), sku('two')]))).toBeNull(); + expect(getSingleMatchedSku(group([]))).toBeNull(); + }); + it('supports a complete set of uniquely labeled SKUs when product attributes are absent', () => { + expect(getLabeledSkuOptions(group([sku('one', 'Small'), sku('two', 'Large')]))).toHaveLength(2); + expect(getLabeledSkuOptions(group([sku('one', 'Same'), sku('two', 'same')]))).toEqual([]); + expect(getLabeledSkuOptions(group([sku('one'), sku('two')], { totalCount: 3 }))).toEqual([]); + expect( + getLabeledSkuOptions(group([sku('one'), sku('two')], { pageInfo: { hasNextPage: true } })), + ).toEqual([]); + expect( + getLabeledSkuOptions({ + ...group([sku('one'), sku('two')]), + attributes: { edges: [{ node: { name: 'size' } }] }, + }), + ).toEqual([]); + }); + it('distinguishes untracked inventory from unavailable inventory', () => { + expect(getAvailableInventoryQuantity(sku('digital'))).toBeNull(); + expect(getAvailableInventoryQuantity({ inventoryCounts: { edges: [] } })).toBeNull(); + expect( + getAvailableInventoryQuantity({ + inventoryCounts: { edges: [{ node: { type: 'ON_HAND', quantity: 8 } }] }, + }), + ).toBe(0); + expect( + getAvailableInventoryQuantity({ + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity: 2 } }] }, + }), + ).toBe(2); + }); +}); diff --git a/packages/commerce-storefront/src/catalog-model.ts b/packages/commerce-storefront/src/catalog-model.ts new file mode 100644 index 00000000..45b4fc39 --- /dev/null +++ b/packages/commerce-storefront/src/catalog-model.ts @@ -0,0 +1,253 @@ +export interface PageInfo { + hasNextPage?: boolean | null; + hasPreviousPage?: boolean | null; + startCursor?: string | null; + endCursor?: string | null; +} + +export interface ConnectionEdge { + cursor?: string | null; + node?: T | null; +} + +export interface Connection { + edges?: Array | null> | null; + pageInfo?: PageInfo | null; + totalCount?: number | null; +} + +export interface PriceRange { + min?: number | null; + max?: number | null; +} + +export interface MediaObject { + id?: string | null; + url?: string | null; + type?: string | null; + label?: string | null; + position?: number | null; +} + +export interface InventoryCount { + id?: string | null; + quantity?: number | null; + type?: string | null; +} + +export interface SKUGroupAttributeValue { + id?: string | null; + name?: string | null; + label?: string | null; +} + +export interface SKUGroupAttribute { + id?: string | null; + name?: string | null; + label?: string | null; + description?: string | null; + htmlDescription?: string | null; + values?: Connection | null; +} + +export type SKUGroupSKU = SKU; + +export interface SKUGroup { + id?: string | null; + name?: string | null; + label?: string | null; + description?: string | null; + htmlDescription?: string | null; + type?: string | null; + priceRange?: PriceRange | null; + compareAtPriceRange?: PriceRange | null; + mediaObjects?: Connection | null; + attributes?: Connection | null; + skus?: Connection | null; +} + +import type { Money } from './cart-model'; + +export interface SKUPrice { + id?: string | null; + value?: Money | null; + compareAtValue?: Money | null; +} + +export interface SKUAttributeValue { + id?: string | null; + name?: string | null; + label?: string | null; +} + +export interface SKU { + id?: string | null; + label?: string | null; + name?: string | null; + description?: string | null; + htmlDescription?: string | null; + code?: string | null; + prices?: Connection | null; + inventoryCounts?: Connection | null; + mediaObjects?: Connection | null; + attributeValues?: Connection | null; +} + +export interface StringInFilter { + in: string[]; +} + +export interface LabelFilter { + contains: string; +} + +export interface SkuGroupsVariables { + first?: number | null; + after?: string | null; + id?: StringInFilter; + listId?: StringInFilter; + label?: LabelFilter; +} + +export interface SkuGroupVariables { + id: string; + first?: number | null; + /** Attribute value `name` fields — the catalog API's `has` filter matches by `name`, not `id`. */ + attributeValues?: string[]; +} + +export interface SkuVariables { + id: string; +} + +export interface SkuGroupsResult { + skuGroups?: Connection | null; +} + +export interface SkuGroupResult { + skuGroup?: SKUGroup | null; +} + +export interface SkuResult { + sku?: SKU | null; +} + +export interface ProductGridVariablesInput { + first?: number | null; + after?: string | null; + productIds?: readonly string[]; + categoryIds?: readonly string[]; + searchQuery?: string; +} + +export interface ProductDetailsVariablesInput { + productId: string; + /** Attribute value `name` fields, not `id`. */ + selectedAttributeValues?: readonly string[]; + /** Number of SKUs to request before attributes are selected. Defaults to 50. */ + skuGroupFirst?: number; +} + +export interface StorefrontProductAttributeValue { + id: string; + name: string; + label: string; +} + +export interface StorefrontProductAttribute { + id: string; + name: string; + label: string; + values: StorefrontProductAttributeValue[]; +} + +type InventoryCountContainer = { + inventoryCounts?: Connection | null; +}; + +type MediaObjectContainer = { + mediaObjects?: Connection | null; +}; + +export function getSingleMatchedSkuId(skuGroup: SKUGroup | null | undefined): string | null { + return getSingleMatchedSku(skuGroup)?.id ?? null; +} + +export function getSingleMatchedSku(skuGroup: SKUGroup | null | undefined): SKU | null { + const connection: Connection | null | undefined = skuGroup?.skus; + if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null; + const edges: Array | null> = connection?.edges ?? []; + const sku: SKU | null | undefined = edges.length === 1 ? edges[0]?.node : null; + return sku?.id ? sku : null; +} + +export function getLabeledSkuOptions(skuGroup: SKUGroup | null | undefined): SKU[] { + if (getProductAttributes(skuGroup).length > 0) return []; + const connection: Connection | null | undefined = skuGroup?.skus; + const edges: Array | null> = connection?.edges ?? []; + if ( + edges.length < 2 || + connection?.pageInfo?.hasNextPage || + (connection?.totalCount ?? edges.length) !== edges.length + ) + return []; + const skus: SKU[] = edges.flatMap((edge: ConnectionEdge | null): SKU[] => + edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : [], + ); + const labels: Set = new Set( + skus.map((sku: SKU): string => (sku.label?.trim() || sku.name?.trim() || '').toLowerCase()), + ); + const ids: Set = new Set( + skus.map((sku: SKU): string | null | undefined => sku.id), + ); + return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : []; +} + +export function getAvailableInventoryQuantity( + item: InventoryCountContainer | null | undefined, +): number | null { + const edges = item?.inventoryCounts?.edges; + if (!edges || edges.length === 0) { + // No inventory records = inventory not tracked (digital goods, services, etc.). + // Return null so callers can distinguish "unlimited" from "out of stock" (0). + return null; + } + return edges.find((edge) => edge?.node?.type === 'AVAILABLE')?.node?.quantity ?? 0; +} + +export function getImageUrls(item: MediaObjectContainer | null | undefined): string[] { + return ( + item?.mediaObjects?.edges + ?.filter((edge) => edge?.node?.type === 'IMAGE' && edge.node.url) + .map((edge) => edge?.node?.url) + .filter((url): url is string => Boolean(url)) ?? [] + ); +} + +export function getPrimaryImageUrl(item: MediaObjectContainer | null | undefined): string | null { + return getImageUrls(item)[0] ?? null; +} + +export function getProductAttributes(skuGroup: SKUGroup | null | undefined): StorefrontProductAttribute[] { + return ( + skuGroup?.attributes?.edges?.map((edge) => { + const attributeNode = edge?.node; + const values = + attributeNode?.values?.edges?.map((valueEdge) => { + const valueNode = valueEdge?.node; + return { + id: valueNode?.id || '', + name: valueNode?.name || '', + label: valueNode?.label || valueNode?.name || '', + }; + }) ?? []; + + return { + id: attributeNode?.id || '', + name: attributeNode?.name || '', + label: attributeNode?.label || attributeNode?.name || '', + values, + }; + }) ?? [] + ); +} diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx new file mode 100644 index 00000000..d72b1dbb --- /dev/null +++ b/packages/commerce-storefront/src/catalog.tsx @@ -0,0 +1,220 @@ +import { useQuery } from '@tanstack/react-query'; +import { type ReactElement, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { message, money, request } from './api'; +import { AddToCartButton, buttonClass } from './cart'; +import { + getPrimaryImageUrl, + getProductAttributes, + getSingleMatchedSku, + type SKU, + type SKUGroup, + type SkuGroupsResult, +} from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { CommerceStatus, StorefrontSurface } from './storefront-surface'; + +export function ProductImage({ + url, + name, + className = '', +}: { + url?: string | null; + name: string; + className?: string; +}): ReactElement { + const [failed, setFailed] = useState(false); + return url && !failed ? ( + {name} setFailed(true)} + /> + ) : ( +
+ No image available +
+ ); +} + +export function ProductCard({ product }: { product: SKUGroup }): ReactElement { + return ( + + + + ); +} + +function ProductCardContent({ product }: { product: SKUGroup }): ReactElement { + const { config } = useCommerce(); + const name: string = product.label ?? product.name ?? 'Product'; + const href: string = `${config.productPath}/${encodeURIComponent(product.id ?? '')}`; + const selectedSku: SKU | null = getSingleMatchedSku(product); + const _skuId: string | null = selectedSku?.id ?? null; + const hasVariants: boolean = getProductAttributes(product).length > 0; + const min: number | null | undefined = product.priceRange?.min; + const max: number | null | undefined = product.priceRange?.max; + return ( +
+ + + +
+

+ + {name} + +

+ {product.description &&

{product.description}

} +
+
+

+ {typeof min === 'number' + ? `${money(min, config.currencyCode)}${typeof max === 'number' && max !== min ? ` – ${money(max, config.currencyCode)}` : ''}` + : 'Price unavailable'} +

+
+ {!hasVariants && selectedSku ? ( + + ) : ( + + {hasVariants ? 'Choose options' : 'View product'} + + )} +
+
+
+
+
+ ); +} + +export interface CatalogProps { + title?: string; + description?: string; + showHeader?: boolean; +} + +export function Catalog(props: CatalogProps): ReactElement { + const { connection } = useCommerce(); + return ( + + {connection === 'ready' ? : } + + ); +} + +function CatalogContent({ + title = 'Shop all products', + description = 'Explore the collection and find your favorites.', + showHeader = true, +}: CatalogProps): ReactElement { + const { config } = useCommerce(); + const [params, setParams] = useSearchParams(); + const after: string = params.get('after') ?? ''; + const urlParams: URLSearchParams = new URLSearchParams({ first: '6' }); + if (after) urlParams.set('after', after); + const products = useQuery({ + retry: false, + queryKey: ['commerce', config.cartScope, 'products', after], + queryFn: ({ signal }) => + request(`/products?${urlParams}`, { + signal, + headers: { 'X-Commerce-Scope': config.cartScope }, + }), + }); + function goToPage(cursor?: string): void { + const next = new URLSearchParams(params); + if (cursor) next.set('after', cursor); + else next.delete('after'); + setParams(next); + } + const nodes: SKUGroup[] = + products.data?.skuGroups?.edges?.flatMap((edge) => (edge?.node?.id ? [edge.node] : [])) ?? []; + const pageInfo = products.data?.skuGroups?.pageInfo; + return ( +
+ {showHeader && ( +
+

{title}

+

{description}

+
+ )} +
+

+ {products.isPending + ? 'Loading products…' + : products.isError + ? 'Products unavailable' + : `Showing ${nodes.length} product${nodes.length === 1 ? '' : 's'}`} +

+
+ {products.isPending && ( +
+ ); +} diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx new file mode 100644 index 00000000..cf2a7d7c --- /dev/null +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -0,0 +1,415 @@ +import { useQuery } from '@tanstack/react-query'; +import { + type CSSProperties, + createContext, + type ReactElement, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { ApiError, CartIdStorage, checkedFetch, message, request, type StorefrontConfig } from './api'; +import { type AddToCartItemInput, addToCart, type CartOrder } from './cart-model'; + +interface CartResponse { + cart: CartOrder | null; +} +export interface CommerceContextValue { + connection: 'loading' | 'ready' | 'error'; + connectionError: string | null; + retryConnection: () => void; + theme: StorefrontTheme; + config: StorefrontConfig; + cart: CartOrder | null; + open: boolean; + setOpen: (open: boolean) => void; + /** Restores the initiating control after the cart drawer closes. */ + restoreFocus: () => void; + pending: boolean; + hydrating: boolean; + error: string | null; + storageWarning: string | null; + announcement: string; + addItem: (item: AddToCartItemInput) => Promise; + changeQuantity: (itemId: string, quantity: number) => Promise; + removeItem: (itemId: string) => Promise; + applyDiscount: (code: string) => Promise; + refresh: () => Promise; + checkout: () => Promise; +} + +const CommerceContext = createContext(null); + +export function useCommerce(): CommerceContextValue { + const context: CommerceContextValue | null = useContext(CommerceContext); + if (!context) + throw new Error('Mount CommerceProvider once around the header and page outlet, inside the router.'); + return context; +} + +export type StorefrontTheme = CSSProperties & { + '--commerce-accent'?: string; + '--commerce-accent-hover'?: string; + '--commerce-on-accent'?: string; + '--commerce-surface'?: string; + '--commerce-text'?: string; + '--commerce-radius'?: string; +}; + +export interface CommerceProviderProps { + children: ReactNode; + catalogPath?: string; + productPath?: string; + /** Applied to storefront surfaces and the portalled cart drawer. */ + theme?: StorefrontTheme; + /** Set only when your server has enabled checkout and this return route exists. */ + checkoutSuccessPath?: string; +} + +async function loadConfig( + signal: AbortSignal, +): Promise> { + const config = await request>('/config', { signal }); + if ( + !config || + typeof config.cartScope !== 'string' || + !config.cartScope.trim() || + typeof config.currencyCode !== 'string' || + !/^[A-Z]{3}$/.test(config.currencyCode) + ) { + throw new Error('The store returned invalid configuration.'); + } + return config; +} + +export function CommerceProvider({ + children, + catalogPath = '/shop', + productPath = '/products', + checkoutSuccessPath, + theme = {}, +}: CommerceProviderProps): ReactElement { + const config = useQuery({ + retry: false, + queryKey: ['commerce', 'configuration'], + queryFn: ({ signal }) => loadConfig(signal), + staleTime: 30_000, + refetchOnWindowFocus: true, + }); + return ( + { + void config.refetch(); + }} + theme={theme} + > + {children} + + ); +} + +function BoundCommerceProvider({ + config, + children, + connection, + connectionError, + retryConnection, + theme, +}: { + connection: CommerceContextValue['connection']; + connectionError: string | null; + retryConnection: () => void; + theme: StorefrontTheme; + config: StorefrontConfig; + children: ReactNode; +}): ReactElement { + const storageKey: string = `godaddy:commerce-storefront:cart:${config.cartScope}`; + const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]); + const ready = connection === 'ready'; + const session = useMemo(() => ({ cartIdStorage, ready }), [cartIdStorage, ready]); + const currentSession = useRef(session); + currentSession.current = session; + const readyRef = useRef(ready); + readyRef.current = ready; + const [cart, setCart] = useState(null); + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + const [hydrating, setHydrating] = useState(true); + const [error, setError] = useState(null); + const [storageWarning, setStorageWarning] = useState(null); + const [announcement, setAnnouncement] = useState(''); + const id = useRef(null); + const currentCart = useRef(null); + const queue = useRef>(Promise.resolve(true)); + const alive = useRef(true); + const opener = useRef(null); + + function rememberOpener(): void { + opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + + function isActive(): boolean { + return alive.current && currentSession.current === session && readyRef.current; + } + + function boundRequest(path: string, init?: RequestInit): Promise { + const headers: Headers = new Headers(init?.headers); + headers.set('X-Commerce-Scope', config.cartScope); + return request(path, { ...init, headers }); + } + + function readId(): string | null { + try { + return cartIdStorage.read(); + } catch { + setStorageWarning('Your browser cannot save the cart. Keep this page open while shopping.'); + return id.current; + } + } + + function commit(next: CartOrder | null): void { + if (!isActive()) return; + id.current = next?.id ?? null; + currentCart.current = next; + setCart(next); + try { + cartIdStorage.write(id.current); + setStorageWarning(null); + } catch { + setStorageWarning('Your browser cannot save the cart. Keep this page open while shopping.'); + } + } + + async function hydrate(): Promise { + const storedId: string | null = readId(); + id.current = storedId; + if (!storedId) { + commit(null); + return; + } + try { + const data: CartResponse = await boundRequest(`/cart/${encodeURIComponent(storedId)}`); + commit(data.cart); + } catch (cause: unknown) { + if (cause instanceof ApiError && (cause.status === 404 || cause.status === 410)) { + commit(null); + return; + } + throw cause; + } + } + + // All mutations share this queue, including the first add that creates the order. + // Never retry writes automatically: a failed response can follow a successful write. + function run(operation: () => Promise, loading: boolean = false): Promise { + const task: Promise = queue.current.then(async (): Promise => { + if (!isActive()) return false; + setPending(true); + if (loading) setHydrating(true); + setError(null); + try { + // The browser lock also serializes mutations from other tabs for this binding. + if (navigator.locks) + await navigator.locks.request(storageKey, async (): Promise => { + if (isActive()) await operation(); + }); + else await operation(); + return isActive(); + } catch (cause: unknown) { + if (isActive()) setError(message(cause)); + return false; + } finally { + if (isActive()) { + setPending(false); + setHydrating(false); + } + } + }); + queue.current = task; + return task; + } + + function refresh(): Promise { + return run(hydrate, true); + } + + // The subscription belongs to a store connection; render-local operations use that session. + // biome-ignore lint/correctness/useExhaustiveDependencies: Restart only when the scope or readiness changes, not on cart state updates. + useEffect((): (() => void) => { + alive.current = true; + id.current = null; + currentCart.current = null; + queue.current = Promise.resolve(true); + setCart(null); + setOpen(false); + setError(null); + setStorageWarning(null); + setPending(false); + setHydrating(ready); + if (ready) void refresh(); + const onStorage = (event: StorageEvent): void => { + if (event.key === storageKey || event.key === null) void refresh(); + }; + const onFocus = (): void => { + void refresh(); + }; + window.addEventListener('storage', onStorage); + window.addEventListener('focus', onFocus); + return (): void => { + alive.current = false; + window.removeEventListener('storage', onStorage); + window.removeEventListener('focus', onFocus); + }; + }, [cartIdStorage, ready]); + + function addItem(item: AddToCartItemInput): Promise { + rememberOpener(); + return run(async (): Promise => { + if (!item.skuId || !item.name || !Number.isInteger(item.quantity) || item.quantity < 1) + throw new Error('Select a product variant and a positive quantity.'); + await hydrate(); + if (!isActive()) return; + const fetchCart: typeof globalThis.fetch = ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const headers: Headers = new Headers(init?.headers); + headers.set('X-Commerce-Scope', config.cartScope); + return checkedFetch(input, { ...init, headers }); + }; + const result: CartResponse = await addToCart( + id.current, + { skuId: item.skuId, name: item.name, quantity: item.quantity }, + fetchCart, + ); + if (!result.cart?.id || !result.cart.lineItems?.length) + throw new Error('Commerce did not return a cart with items. Refresh before retrying.'); + if (!isActive()) return; + commit(result.cart); + setAnnouncement(`${item.name} added to your cart.`); + setOpen(true); + }); + } + + function mutateItem(itemId: string, method: string, quantity?: number): Promise { + return run(async (): Promise => { + await hydrate(); + if (!isActive()) return; + if (!id.current || !currentCart.current?.lineItems?.some((item) => item.id === itemId)) + throw new Error('This item is no longer in the cart.'); + if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 1)) + throw new Error('Quantity must be a positive whole number.'); + const result: CartResponse = await boundRequest( + `/cart/${encodeURIComponent(id.current)}/items/${encodeURIComponent(itemId)}`, + { + method, + ...(quantity !== undefined ? { body: JSON.stringify({ quantity }) } : {}), + }, + ); + if (!result.cart?.id) + throw new Error('Commerce did not return the updated cart. Refresh before retrying.'); + if (!isActive()) return; + commit(result.cart); + setAnnouncement(method === 'DELETE' ? 'Item removed from cart.' : 'Cart quantity updated.'); + }); + } + + function applyDiscount(code: string): Promise { + return run(async (): Promise => { + await hydrate(); + if (!isActive()) return; + if (!id.current || !code.trim()) throw new Error('Add an item and enter a promotion code.'); + const codes: string[] = [ + ...new Set([ + ...(currentCart.current?.discounts?.flatMap((discount) => (discount.code ? [discount.code] : [])) ?? + []), + code.trim(), + ]), + ]; + const result: CartResponse = await boundRequest( + `/cart/${encodeURIComponent(id.current)}/discounts`, + { method: 'POST', body: JSON.stringify({ discountCodes: codes }) }, + ); + if (!result.cart?.id) throw new Error('Commerce did not return the updated cart.'); + if (!isActive()) return; + commit(result.cart); + setAnnouncement('Promotion codes updated.'); + }); + } + + function checkout(): Promise { + return run(async (): Promise => { + if (!config.checkoutSuccessPath) throw new Error('Checkout has not been enabled for this store.'); + await hydrate(); + if (!isActive()) return; + if (!id.current || !currentCart.current?.lineItems?.length) + throw new Error('Add an item before checking out.'); + const origin: string = window.location.origin; + const successUrl: URL = new URL(config.checkoutSuccessPath, origin); + successUrl.searchParams.set('orderId', id.current); + const session: { url?: unknown } = await boundRequest('/checkout', { + method: 'POST', + body: JSON.stringify({ + draftOrderId: id.current, + returnUrl: new URL(config.catalogPath, origin).href, + successUrl: successUrl.href, + }), + }); + if (!isActive()) return; + if (typeof session.url !== 'string' || !session.url.trim()) + throw new Error('Commerce did not return a checkout URL.'); + const url: URL = new URL(session.url, origin); + if (url.protocol !== 'https:') throw new Error('Commerce returned an invalid checkout URL.'); + window.location.assign(url.href); + }); + } + + return ( + { + if (next) rememberOpener(); + setOpen(next); + }, + restoreFocus: () => { + opener.current?.focus(); + }, + pending, + hydrating, + error, + storageWarning, + announcement, + addItem, + changeQuantity: (itemId, quantity) => mutateItem(itemId, 'PATCH', quantity), + removeItem: (itemId) => mutateItem(itemId, 'DELETE'), + applyDiscount, + refresh, + checkout, + }} + > + {children} + + {announcement} + + + ); +} diff --git a/packages/commerce-storefront/src/commerce-storefront.tsx b/packages/commerce-storefront/src/commerce-storefront.tsx new file mode 100644 index 00000000..00ed0f06 --- /dev/null +++ b/packages/commerce-storefront/src/commerce-storefront.tsx @@ -0,0 +1,12 @@ +import { CartDrawer } from './cart'; +import { CommerceProvider, type CommerceProviderProps } from './commerce-provider'; + +/** Mount once inside the application's Router and QueryClientProvider. */ +export function CommerceStorefront({ children, ...props }: CommerceProviderProps) { + return ( + + {children} + + + ); +} diff --git a/packages/commerce-storefront/src/index.ts b/packages/commerce-storefront/src/index.ts new file mode 100644 index 00000000..43a74992 --- /dev/null +++ b/packages/commerce-storefront/src/index.ts @@ -0,0 +1,24 @@ +export { ApiError, money, type StorefrontConfig } from './api'; +export { AddToCartButton, CartButton, CartDrawer } from './cart'; +export type * from './cart-model'; +export { getCartSummaryTotals } from './cart-model'; +export { Catalog, type CatalogProps, ProductCard } from './catalog'; +export type * from './catalog-model'; +export { + getAvailableInventoryQuantity, + getImageUrls, + getLabeledSkuOptions, + getPrimaryImageUrl, + getProductAttributes, + getSingleMatchedSku, +} from './catalog-model'; +export { + type CommerceContextValue, + CommerceProvider, + type CommerceProviderProps, + type StorefrontTheme, + useCommerce, +} from './commerce-provider'; +export { CommerceStorefront } from './commerce-storefront'; +export { ProductDetails } from './product-details'; +export { CommerceStatus } from './storefront-surface'; diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx new file mode 100644 index 00000000..1692cd47 --- /dev/null +++ b/packages/commerce-storefront/src/product-details.tsx @@ -0,0 +1,293 @@ +import { useQuery } from '@tanstack/react-query'; +import { type ReactElement, useId, useState } from 'react'; +import { Link, useParams, useSearchParams } from 'react-router'; +import { message, money, request } from './api'; +import { AddToCartButton, buttonClass, inputClass } from './cart'; +import { ProductImage } from './catalog'; +import { + getAvailableInventoryQuantity, + getImageUrls, + getLabeledSkuOptions, + getProductAttributes, + getSingleMatchedSku, + type SKU, + type SkuGroupResult, +} from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { CommerceStatus, StorefrontSurface } from './storefront-surface'; + +export function ProductDetails(): ReactElement { + const { productId = '' } = useParams(); + const { connection } = useCommerce(); + return ( + + {connection === 'ready' ? ( + + ) : ( + + )} + + ); +} + +function ProductDetailsContent({ productId }: { productId: string }): ReactElement { + const { config } = useCommerce(); + const fieldId = useId(); + const [params, setParams] = useSearchParams(); + const [quantity, setQuantity] = useState(1); + const [imageIndex, setImageIndex] = useState(0); + const product = useQuery({ + retry: false, + queryKey: ['commerce', config.cartScope, 'product', productId], + queryFn: ({ signal }) => + request(`/products/${encodeURIComponent(productId)}`, { + signal, + headers: { 'X-Commerce-Scope': config.cartScope }, + }), + }); + const group = product.data?.skuGroup; + const attributes = getProductAttributes(group); + const skuOptions: SKU[] = getLabeledSkuOptions(group); + const explicitSku: SKU | undefined = skuOptions.find((sku: SKU): boolean => sku.id === params.get('sku')); + const selections: string[] = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? ''); + const complete: boolean = + skuOptions.length > 0 + ? !!explicitSku + : attributes.every((attribute, index) => + attribute.values.some((value) => value.name === selections[index]), + ); + const selectionParams: URLSearchParams = new URLSearchParams(); + selections.filter(Boolean).forEach((value) => { + selectionParams.append('attributeValues', value); + }); + const matched = useQuery({ + retry: false, + queryKey: ['commerce', config.cartScope, 'product-variants', productId, selections], + queryFn: ({ signal }) => + request(`/products/${encodeURIComponent(productId)}?${selectionParams}`, { + signal, + headers: { 'X-Commerce-Scope': config.cartScope }, + }), + enabled: !!group && attributes.length > 0 && complete, + }); + const selectionVerified: boolean = + complete && + product.isSuccess && + !product.isFetching && + (attributes.length === 0 || (matched.isSuccess && !matched.isFetching)); + const selectedSku: SKU | null = selectionVerified + ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) + : null; + const skuId: string | null = selectedSku?.id ?? null; + const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node; + const selectedImages: string[] = getImageUrls(selectedSku); + const images: string[] = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))]; + const available: number | null = getAvailableInventoryQuantity(selectedSku); + const name: string = group?.label ?? group?.name ?? 'Product'; + const selectOption = (attribute: string, value: string): void => { + const next: URLSearchParams = new URLSearchParams(params); + if (value) next.set(`option.${attribute}`, value); + else next.delete(`option.${attribute}`); + setParams(next); + setQuantity(1); + setImageIndex(0); + }; + if (product.isPending) return

Loading product…

; + if (product.isError) + return ( +
+

Product unavailable

+

{message(product.error)}

+ +
+ ); + if (!group) + return ( +
+

Product not found

+ + Back to shop + +
+ ); + return ( +
+ + ← Back to shop + +
+
+
+ +
+ {images.length > 1 && ( +
+ {images.map((url, index) => ( + + ))} +
+ )} +
+
+

{name}

+
+ {skuPrice?.value?.value != null + ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) + : group.priceRange?.min != null + ? `From ${money(group.priceRange.min, config.currencyCode)}` + : 'Price unavailable'} + {skuPrice?.compareAtValue?.value != null && + skuPrice.value?.value != null && + skuPrice.compareAtValue.value > skuPrice.value.value && ( + + {money( + skuPrice.compareAtValue.value, + skuPrice.compareAtValue.currencyCode ?? config.currencyCode, + )} + + )} +
+

+ {selectedSku?.description ?? group.description} +

+
+ {attributes.map((attribute, index) => ( +
+ {attribute.label} + +
+ ))} + {skuOptions.length > 0 && ( +
+ + +
+ )} + {matched.isError ? ( +
+

{message(matched.error)}

+ +
+ ) : null} + {!complete && ( +

+ Select all options to add this item to your cart. +

+ )} + {selectionVerified && attributes.length > 0 && !skuId && ( +

+ This combination is unavailable. Choose different options. +

+ )} + {selectionVerified && attributes.length === 0 && !skuId && ( +

This product is not currently available to purchase.

+ )} + {complete && (product.isFetching || matched.isFetching) && ( +

Checking this variant…

+ )} + {selectedSku && ( + <> +

+ {available === null + ? 'Available to order' + : available === 0 + ? 'This variant is out of stock' + : `${available} available`} +

+
+ + setQuantity(event.target.valueAsNumber)} + /> +
+ + + )} + {!selectedSku && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/commerce-storefront/src/storefront-surface.tsx b/packages/commerce-storefront/src/storefront-surface.tsx new file mode 100644 index 00000000..7d30ed2c --- /dev/null +++ b/packages/commerce-storefront/src/storefront-surface.tsx @@ -0,0 +1,36 @@ +import type { ReactElement, ReactNode } from 'react'; +import { useCommerce } from './commerce-provider'; + +export function StorefrontSurface({ + children, + className = '', +}: { + children: ReactNode; + className?: string; +}): ReactElement { + const { theme } = useCommerce(); + return ( +
+ {children} +
+ ); +} + +export function CommerceStatus(): ReactElement | null { + const { connection, connectionError, retryConnection } = useCommerce(); + if (connection === 'ready') return null; + return ( + + {connection === 'loading' ? ( +

Connecting to the store…

+ ) : ( +
+

{connectionError}

+ +
+ )} +
+ ); +} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx new file mode 100644 index 00000000..b14210e0 --- /dev/null +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -0,0 +1,443 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactNode, useState } from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, CartIdStorage, money, request } from './api'; +import { AddToCartButton, CartButton } from './cart'; +import { addToCart, type CartOrder } from './cart-model'; +import { Catalog } from './catalog'; +import type { SKUGroup } from './catalog-model'; +import { type CommerceContextValue, useCommerce } from './commerce-provider'; +import { CommerceStorefront } from './commerce-storefront'; +import { ProductDetails } from './product-details'; + +const configuration = { cartScope: 'store-one', currencyCode: 'USD' }; +const storageKey = 'godaddy:commerce-storefront:cart:store-one'; +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const cart = (id = 'cart-1', quantity = 1): CartOrder => ({ + id, + lineItems: [{ id: 'line-1', skuId: 'sku-1', name: 'Mug', quantity }], + totals: { total: { value: quantity * 1200, currencyCode: 'USD' } }, +}); +const item = { skuId: 'sku-1', name: 'Mug', quantity: 1 }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function mockApi(handler: (path: string, init?: RequestInit) => Response | Promise) { + const fn = vi.fn((input: RequestInfo | URL, init?: RequestInit) => + Promise.resolve(handler(String(input), init)), + ); + vi.stubGlobal('fetch', fn); + return fn; +} +function mount(children?: ReactNode, path = '/shop', checkoutSuccessPath?: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + let context: CommerceContextValue; + function Observe() { + context = useCommerce(); + return null; + } + function Location() { + const location = useLocation(); + return {location.search}; + } + const view = render( + + + + + + {children} + + + , + ); + return { ...view, client, context: () => context }; +} +async function connected(view: ReturnType) { + await waitFor(() => expect(view.context().connection).toBe('ready')); + await waitFor(() => expect(view.context().hydrating).toBe(false)); +} + +describe('connection boundary', () => { + it('preserves the surrounding app and its state while connecting, failing and retrying', async () => { + const first = deferred(); + const api = mockApi(() => first.promise); + function Shell() { + const [value, setValue] = useState(''); + return ( + <> + + + + ); + } + mount(); + const field = screen.getByRole('textbox'); + fireEvent.change(field, { target: { value: 'keep me' } }); + expect(screen.getByText('Connecting to the store…')).toBeVisible(); + await act(async () => first.resolve(response({ error: 'Store offline' }, 503))); + expect(await screen.findByText('Store offline')).toBeVisible(); + expect(screen.getByRole('textbox')).toBe(field); + api.mockImplementation(async (input) => + String(input).endsWith('/config') ? response(configuration) : response({ skuGroups: { edges: [] } }), + ); + await userEvent.click(screen.getByRole('button', { name: 'Retry connection' })); + expect(await screen.findByText('No products available.')).toBeVisible(); + expect(field).toHaveValue('keep me'); + expect(screen.getByRole('textbox')).toBe(field); + }); + it('rejects malformed server configuration before requesting catalog or cart', async () => { + const api = mockApi(() => response({ cartScope: '', currencyCode: 'invalid' })); + mount(); + expect(await screen.findByText('The store returned invalid configuration.')).toBeVisible(); + expect(api).toHaveBeenCalledTimes(1); + }); +}); + +describe('shared cart', () => { + it('returns keyboard focus to the add trigger and never submits a host form', async () => { + const submit = vi.fn((event) => event.preventDefault()); + mockApi((path) => (path.endsWith('/config') ? response(configuration) : response({ cart: cart() }))); + const view = mount( +
+ + + , + ); + await connected(view); + const trigger = screen.getByRole('button', { name: 'Add to cart' }); + trigger.focus(); + await userEvent.keyboard('{Enter}'); + await screen.findByRole('dialog'); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(submit).not.toHaveBeenCalled(); + }); + + it('serializes two first additions, persists the cart, and themes the accessible drawer', async () => { + const create = deferred(); + const writes: string[] = []; + const api = mockApi((path, init) => { + if (path.endsWith('/config')) return response(configuration); + expect(new Headers(init?.headers).get('X-Commerce-Scope')).toBe('store-one'); + if (init?.method === 'POST') { + writes.push(path); + return path.endsWith('/items') ? response({ cart: cart('cart-1', 2) }) : create.promise; + } + return response({ cart: cart() }); + }); + const view = mount(); + await connected(view); + let tasks: Promise[] = []; + act(() => { + tasks = [view.context().addItem(item), view.context().addItem(item)]; + }); + await waitFor(() => expect(writes).toEqual(['/api/commerce/cart'])); + await act(async () => { + create.resolve(response({ cart: cart() })); + await Promise.all(tasks); + }); + expect(writes).toEqual(['/api/commerce/cart', '/api/commerce/cart/cart-1/items']); + expect(view.context().cart?.lineItems?.[0]?.quantity).toBe(2); + expect(localStorage.getItem(storageKey)).toBe('cart-1'); + const dialog = screen.getByRole('dialog', { name: 'Your cart' }); + expect(dialog.closest('.commerce-storefront')).toHaveStyle('--commerce-accent: #123456'); + expect(within(dialog).getByText('Checkout is not available yet.')).toBeVisible(); + expect(JSON.parse(String(api.mock.calls.find((call) => call[1]?.method === 'POST')?.[1]?.body))).toEqual({ + lineItems: [item], + }); + }); + it.each([404, 410])( + 'clears an expired cart (%i) without treating server/network errors as expiry', + async (status) => { + localStorage.setItem(storageKey, 'expired'); + mockApi((path) => + path.endsWith('/config') ? response(configuration) : response({ error: 'Expired' }, status), + ); + const view = mount(); + await connected(view); + expect(view.context().cart).toBeNull(); + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(view.context().error).toBeNull(); + }, + ); + it('starts a new cart after the server reports that a completed order is no longer a draft', async () => { + localStorage.setItem(storageKey, 'completed-cart'); + const api = mockApi((path, init) => { + if (path.endsWith('/config')) return response(configuration); + if (init?.method === 'POST') return response({ cart: cart('new-cart') }); + return response({ cart: null }); + }); + const view = mount(); + await connected(view); + expect(localStorage.getItem(storageKey)).toBeNull(); + await act(async () => { + expect(await view.context().addItem(item)).toBe(true); + }); + expect(localStorage.getItem(storageKey)).toBe('new-cart'); + expect(view.context().cart?.id).toBe('new-cart'); + expect(api.mock.calls.filter((call) => call[1]?.method === 'POST').map((call) => call[0])).toEqual([ + '/api/commerce/cart', + ]); + }); + + it('keeps a cart ID after hydration fails and does not create a duplicate order', async () => { + localStorage.setItem(storageKey, 'existing'); + const api = mockApi((path) => + path.endsWith('/config') ? response(configuration) : response({ error: 'Offline' }, 503), + ); + const view = mount(); + await connected(view); + await act(async () => { + expect(await view.context().addItem(item)).toBe(false); + }); + expect(localStorage.getItem(storageKey)).toBe('existing'); + expect(view.context().error).toBe('Offline'); + expect(api.mock.calls.every((call) => call[1]?.method !== 'POST')).toBe(true); + }); + it('ignores an old binding response after the store changes', async () => { + const add = deferred(); + mockApi((path, init) => + path.endsWith('/config') + ? response(configuration) + : init?.method === 'POST' + ? add.promise + : response({ cart: null }), + ); + const view = mount(); + await connected(view); + let task!: Promise; + act(() => { + task = view.context().addItem(item); + }); + await waitFor(() => expect(view.context().pending).toBe(true)); + // Allow the add request to start before switching the server-provided binding. + await act(async () => { + await Promise.resolve(); + }); + act(() => { + view.client.setQueryData(['commerce', 'configuration'], { ...configuration, cartScope: 'store-two' }); + }); + await connected(view); + await act(async () => { + add.resolve(response({ cart: cart() })); + expect(await task).toBe(false); + }); + expect(view.context().cart).toBeNull(); + expect(view.context().open).toBe(false); + expect(view.context().announcement).toBe(''); + expect(localStorage.getItem('godaddy:commerce-storefront:cart:store-two')).toBeNull(); + }); + it('does not revive a stale operation when the same store disconnects and reconnects', async () => { + const add = deferred(); + const api = mockApi((path, init) => + path.endsWith('/config') + ? response(configuration) + : init?.method === 'POST' + ? add.promise + : response({ cart: null }), + ); + const view = mount(); + await connected(view); + let task!: Promise; + act(() => { + task = view.context().addItem(item); + }); + await waitFor(() => expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true)); + api.mockImplementation(async (input) => + String(input).endsWith('/config') ? response({ error: 'Disconnected' }, 503) : response({ cart: null }), + ); + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + await waitFor(() => expect(view.context().connection).toBe('error')); + act(() => { + view.client.setQueryData(['commerce', 'configuration'], configuration); + }); + await connected(view); + await act(async () => { + add.resolve(response({ cart: cart() })); + expect(await task).toBe(false); + }); + expect(view.context().cart).toBeNull(); + expect(view.context().open).toBe(false); + }); + it('requires explicit checkout enablement and rejects an unsafe session URL', async () => { + localStorage.setItem(storageKey, 'cart-1'); + const api = mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : path.endsWith('/checkout') + ? response({ url: 'javascript:alert(1)' }) + : response({ cart: cart() }), + ); + const disabled = mount(); + await connected(disabled); + await act(async () => { + expect(await disabled.context().checkout()).toBe(false); + }); + expect(disabled.context().error).toBe('Checkout has not been enabled for this store.'); + expect(api.mock.calls.some((call) => String(call[0]).endsWith('/checkout'))).toBe(false); + disabled.unmount(); + const enabled = mount(undefined, '/shop', '/order-return'); + await connected(enabled); + act(() => enabled.context().setOpen(true)); + expect(await screen.findByRole('button', { name: 'Proceed to Checkout' })).toBeVisible(); + await act(async () => { + expect(await enabled.context().checkout()).toBe(false); + }); + expect(enabled.context().error).toBe('Commerce returned an invalid checkout URL.'); + const body = JSON.parse( + String(api.mock.calls.find((call) => String(call[0]).endsWith('/checkout'))?.[1]?.body), + ); + expect(body).toMatchObject({ draftOrderId: 'cart-1' }); + expect(new URL(body.successUrl).pathname).toBe('/order-return'); + expect(new URL(body.successUrl).searchParams.get('orderId')).toBe('cart-1'); + }); +}); + +const group: SKUGroup = { + id: 'shirt', + label: 'Shirt', + attributes: { + edges: [ + { + node: { + name: 'color', + label: 'Color', + values: { + edges: [ + { node: { id: 'id-blue', name: 'blue', label: 'Blue' } }, + { node: { id: 'id-red', name: 'red', label: 'Red' } }, + ], + }, + }, + }, + ], + }, + skus: { totalCount: 2, edges: [] }, +}; +describe('catalog and product selection', () => { + it('waits for verified attribute names and blocks sold-out variants', async () => { + const api = mockApi((path) => { + if (path.endsWith('/config')) return response(configuration); + const color = new URL(path, 'https://example.test').searchParams.get('attributeValues'); + const selected = { + id: `sku-${color}`, + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity: color === 'blue' ? 3 : 0 } }] }, + prices: { edges: [{ node: { value: { value: 1200, currencyCode: 'USD' } } }] }, + }; + return response({ + skuGroup: color ? { ...group, skus: { totalCount: 1, edges: [{ node: selected }] } } : group, + }); + }); + const view = mount( + + } /> + , + '/products/shirt', + ); + await connected(view); + expect(await screen.findByRole('button', { name: 'Add to cart' })).toBeDisabled(); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Color' }), 'blue'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add to cart' })).toBeEnabled()); + expect(api.mock.calls.some((call) => String(call[0]).includes('attributeValues=blue'))).toBe(true); + expect(screen.getByTestId('product-price')).toHaveTextContent('$12.00'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Color' }), 'red'); + expect(await screen.findByRole('button', { name: 'Out of stock' })).toBeDisabled(); + }); + it('preserves unrelated URL state during cursor pagination and binds catalog requests', async () => { + const api = mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : response({ + skuGroups: { + edges: [], + pageInfo: { hasNextPage: !path.includes('after='), endCursor: 'cursor+next' }, + }, + }), + ); + const view = mount(, '/shop?campaign=spring'); + await connected(view); + await userEvent.click(await screen.findByRole('button', { name: 'Next page' })); + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('campaign=spring&after=cursor%2Bnext'), + ); + await userEvent.click(screen.getByRole('button', { name: 'First page' })); + expect(screen.getByTestId('location')).toHaveTextContent('?campaign=spring'); + const catalogRequests = api.mock.calls.filter((call) => String(call[0]).includes('/products')); + expect( + catalogRequests.every((call) => new Headers(call[1]?.headers).get('X-Commerce-Scope') === 'store-one'), + ).toBe(true); + }); + it('lets a host page own the single catalog h1', async () => { + mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : response({ skuGroups: { edges: [], pageInfo: {} } }), + ); + const view = mount( +
+

Products

+ +
, + ); + await connected(view); + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(screen.getByRole('heading', { level: 1, name: 'Products' })).toBeVisible(); + }); +}); + +describe('storage and transport', () => { + it('encodes reserved characters in an existing cart ID', async () => { + const fetcher = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + response({ cart: cart('order/with?reserved#characters') }), + ); + await addToCart('order/with?reserved#characters', item, fetcher); + expect(fetcher.mock.calls[0]?.[0]).toBe('/api/commerce/cart/order%2Fwith%3Freserved%23characters/items'); + }); + + it('retains an unsaved ID and pending clear when storage writes fail but reads succeed', () => { + localStorage.setItem('cart', 'old'); + const storage = new CartIdStorage('cart'); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('Quota exceeded'); + }); + expect(() => storage.write('new')).toThrow('Quota exceeded'); + expect(storage.read()).toBe('new'); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('Denied'); + }); + expect(() => storage.write(null)).toThrow('Denied'); + expect(storage.read()).toBeNull(); + }); + it('surfaces non-JSON API failures as typed errors with the original cause', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('Unavailable', { status: 502 })), + ); + const error = await request('/config').catch((error) => error); + expect(error).toBeInstanceOf(ApiError); + if (!(error instanceof ApiError)) throw error; + expect(error.status).toBe(502); + expect(error.cause).toBeInstanceOf(SyntaxError); + }); + it('formats currency minor units according to the currency exponent', () => { + expect(money(1234, 'USD')).toBe('$12.34'); + expect(money(1234, 'JPY')).toBe('¥1,234'); + expect(money(1234, 'KWD')).toContain('1.234'); + }); +}); diff --git a/packages/commerce-storefront/src/styles.css b/packages/commerce-storefront/src/styles.css new file mode 100644 index 00000000..b5202ce5 --- /dev/null +++ b/packages/commerce-storefront/src/styles.css @@ -0,0 +1,29 @@ +@layer theme, base, components, utilities; +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/utilities.css' layer(utilities) source(none); +@source './*.tsx'; + +@theme inline { + --color-commerce-accent: var(--commerce-accent, #171717); + --color-commerce-accent-hover: var(--commerce-accent-hover, #404040); + --color-commerce-on-accent: var(--commerce-on-accent, #fff); + --color-white: var(--commerce-surface, #fff); + --color-neutral-900: var(--commerce-text, #171717); + --radius-lg: var(--commerce-radius, 0.5rem); +} + +@layer base { + .commerce-storefront { color: var(--commerce-text, #171717); font-family: inherit; line-height: 1.5; } + .commerce-storefront *, .commerce-storefront *::before, .commerce-storefront *::after { + box-sizing: border-box; border-width: 0; border-style: solid; + } + .commerce-storefront :where(h1,h2,h3,p,ul,dl,dd,fieldset) { margin: 0; padding: 0; } + .commerce-storefront :where(h1,h2,h3) { font-size: inherit; font-weight: inherit; } + .commerce-storefront :where(button,input,select) { font: inherit; color: inherit; background: transparent; border-radius: 0; } + .commerce-storefront :where(button) { cursor: pointer; } + .commerce-storefront :where(a) { color: inherit; text-decoration: inherit; } + .commerce-storefront :where(img) { display: block; max-width: 100%; } + .commerce-storefront :where(ul) { list-style: none; } + .commerce-inline { display: inline-block; } + .commerce-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } +} diff --git a/packages/commerce-storefront/src/test-setup.ts b/packages/commerce-storefront/src/test-setup.ts new file mode 100644 index 00000000..f0c49dae --- /dev/null +++ b/packages/commerce-storefront/src/test-setup.ts @@ -0,0 +1,10 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +afterEach(() => { + cleanup(); + if (typeof localStorage !== 'undefined') localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); diff --git a/packages/commerce-storefront/tsconfig.json b/packages/commerce-storefront/tsconfig.json new file mode 100644 index 00000000..165a87bf --- /dev/null +++ b/packages/commerce-storefront/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true + }, + "include": [ + "src", + "tsdown.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/commerce-storefront/tsdown.config.ts b/packages/commerce-storefront/tsdown.config.ts new file mode 100644 index 00000000..5ec71054 --- /dev/null +++ b/packages/commerce-storefront/tsdown.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'tsdown'; +export default defineConfig({ entry: ['src/index.ts'], dts: true, external: [/^react($|\/)/, /^react-dom($|\/)/, 'react-router', '@tanstack/react-query'] }); diff --git a/packages/commerce-storefront/vitest.config.ts b/packages/commerce-storefront/vitest.config.ts new file mode 100644 index 00000000..5faa4ca1 --- /dev/null +++ b/packages/commerce-storefront/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'] } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 936a0832..5eb9cf7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,40 @@ importers: specifier: ^5.8.3 version: 5.9.2 + examples/commerce-storefront: + dependencies: + '@godaddy/commerce-storefront': + specifier: workspace:* + version: link:../../packages/commerce-storefront + '@tanstack/react-query': + specifier: ^5.66.0 + version: 5.90.5(react@19.2.0) + react: + specifier: ^19 + version: 19.2.0 + react-dom: + specifier: ^19 + version: 19.2.0(react@19.2.0) + react-router: + specifier: ^7.0.0 + version: 7.18.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + devDependencies: + '@types/node': + specifier: ^22.13.1 + version: 22.18.12 + '@types/react': + specifier: ^19.0.8 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.2(@types/react@19.2.2) + typescript: + specifier: ~5.7.3 + version: 5.7.3 + vite: + specifier: ^6.4.1 + version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1) + examples/nextjs: dependencies: '@godaddy/localizations': @@ -152,6 +186,94 @@ importers: specifier: ^2.2.5 version: 2.2.5 + packages/commerce-server: + devDependencies: + '@biomejs/biome': + specifier: ^2.3.2 + version: 2.3.2 + '@types/express': + specifier: ^5.0.3 + version: 5.0.3 + '@types/node': + specifier: ^22.13.1 + version: 22.18.12 + express: + specifier: ^5.1.0 + version: 5.2.1 + tsdown: + specifier: ^0.15.6 + version: 0.15.9(typescript@5.7.3) + typescript: + specifier: ~5.7.3 + version: 5.7.3 + vitest: + specifier: 5.0.0 + version: 5.0.0(@types/node@22.18.12)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1)) + + packages/commerce-storefront: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.6 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.2 + version: 2.3.2 + '@tailwindcss/cli': + specifier: ^4.1.10 + version: 4.1.15 + '@tanstack/react-query': + specifier: ^5.66.0 + version: 5.90.5(react@19.2.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^22.13.1 + version: 22.18.12 + '@types/react': + specifier: ^19.0.8 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.2(@types/react@19.2.2) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + postcss: + specifier: ^8.5.3 + version: 8.5.23 + react: + specifier: ^19 + version: 19.2.0 + react-dom: + specifier: ^19 + version: 19.2.0(react@19.2.0) + react-router: + specifier: ^7.0.0 + version: 7.18.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + tailwindcss: + specifier: ^4.1.4 + version: 4.1.16 + tailwindcss-v3: + specifier: npm:tailwindcss@^3.4.19 + version: tailwindcss@3.4.19(tsx@4.20.6)(yaml@2.9.1) + tsdown: + specifier: ^0.15.6 + version: 0.15.9(typescript@5.7.3) + typescript: + specifier: ~5.7.3 + version: 5.7.3 + vitest: + specifier: 5.0.0 + version: 5.0.0(@types/node@22.18.12)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1)) + packages/eslint-config-godaddy: dependencies: '@eslint/js': @@ -1473,6 +1595,18 @@ packages: cpu: [x64] os: [win32] + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@oozcitak/dom@1.15.10': resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==} engines: {node: '>=8.0'} @@ -3014,6 +3148,10 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3055,6 +3193,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -3063,6 +3204,9 @@ packages: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -3171,6 +3315,10 @@ packages: birpc@2.6.1: resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3190,6 +3338,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3217,6 +3369,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + caniuse-lite@1.0.30001737: resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==} @@ -3272,6 +3428,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -3279,12 +3439,36 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + country-flag-icons@1.5.21: resolution: {integrity: sha512-0KmU4oeiyAM+F+atzK99ghQDQJKxEY3tiDhnRraVFL4o65rZgrmrx7xKi0b+hxcVpcEpuUbu+KCC6TKTZQTDcA==} @@ -3305,6 +3489,11 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -3372,6 +3561,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3388,10 +3581,16 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3431,6 +3630,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.211: resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==} @@ -3457,6 +3659,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -3519,6 +3725,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3625,10 +3834,18 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -3642,6 +3859,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3660,6 +3881,9 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3679,6 +3903,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3701,6 +3929,14 @@ packages: resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3853,6 +4089,10 @@ packages: htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3869,6 +4109,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3892,6 +4136,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: @@ -3907,6 +4154,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3982,6 +4233,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4036,6 +4290,10 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4187,6 +4445,13 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4233,6 +4498,18 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4241,10 +4518,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -4273,6 +4558,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4286,6 +4574,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + next@16.3.4: resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} @@ -4336,6 +4628,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4368,6 +4664,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4412,6 +4712,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4427,6 +4731,9 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4459,10 +4766,57 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -4493,6 +4847,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -4504,15 +4862,30 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} rambda@7.5.0: resolution: {integrity: sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-day-picker@8.10.1: resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} peerDependencies: @@ -4582,6 +4955,16 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4596,6 +4979,9 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + read-cache@1.0.2: + resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -4631,10 +5017,19 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -4672,9 +5067,16 @@ packages: rou3@0.7.8: resolution: {integrity: sha512-21X/el5fdOaEsqwl3an/d9kpZ8hshVIyrwFCpsoleJ4ccAGRbN+PVoxyXzWXkHDxfMkVnLe4yzx+imz2qoem2Q==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4718,6 +5120,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.3.3: resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} engines: {node: '>=10'} @@ -4728,6 +5134,13 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4740,6 +5153,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.4: resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} @@ -4765,6 +5181,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -4777,6 +5197,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4826,6 +5250,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -4897,6 +5325,11 @@ packages: babel-plugin-macros: optional: true + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -4910,6 +5343,11 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + tailwindcss@4.1.15: resolution: {integrity: sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==} @@ -4920,6 +5358,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -4967,6 +5412,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4988,6 +5437,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsdown@0.15.9: resolution: {integrity: sha512-C0EJYpXIYdlJokTumIL4lmv/wEiB20oa6iiYsXFE7Q0VKF3Ju6TQ7XAn4JQdm+2iQGEfl8cnEKcX5DB7iVR5Dw==} engines: {node: '>=20.19.0'} @@ -5026,6 +5478,10 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5073,6 +5529,10 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin@2.3.10: resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} engines: {node: '>=18.12.0'} @@ -5111,6 +5571,13 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -6261,6 +6728,18 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.4': optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + '@oozcitak/dom@1.15.10': dependencies: '@oozcitak/infra': 1.0.8 @@ -7802,6 +8281,15 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/mocker@5.0.0(vite@6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1) + '@vitest/mocker@5.0.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -7813,6 +8301,11 @@ snapshots: '@vitest/spy@5.0.0': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -7842,6 +8335,8 @@ snapshots: ansis@4.2.0: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7849,6 +8344,8 @@ snapshots: are-docs-informative@0.0.2: {} + arg@5.0.2: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -7974,6 +8471,20 @@ snapshots: birpc@2.6.1: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -7996,6 +8507,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.4) + bytes@3.1.2: {} + cac@6.7.14: {} cac@7.0.0: {} @@ -8027,6 +8540,8 @@ snapshots: callsites@3.1.0: {} + camelcase-css@2.0.1: {} + caniuse-lite@1.0.30001737: {} chai@6.2.2: {} @@ -8102,14 +8617,28 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@4.1.1: {} + comment-parser@1.4.1: {} concat-map@0.0.1: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cookie-es@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + country-flag-icons@1.5.21: {} cross-fetch@3.2.0: @@ -8136,6 +8665,8 @@ snapshots: css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -8200,6 +8731,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@1.0.3: {} @@ -8208,8 +8741,12 @@ snapshots: detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} + diff@8.0.2: {} + dlv@1.1.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -8246,6 +8783,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.211: {} embla-carousel-react@8.6.0(react@19.2.0): @@ -8266,6 +8805,8 @@ snapshots: empathic@2.0.0: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -8418,6 +8959,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-jsdoc@50.8.0(eslint@10.10.0(jiti@2.6.1)): @@ -8580,8 +9123,43 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + expect-type@1.4.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.7: {} extract-files@9.0.0: {} @@ -8590,6 +9168,14 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -8606,6 +9192,10 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -8624,6 +9214,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -8654,6 +9255,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -8814,6 +9419,14 @@ snapshots: domutils: 3.2.2 entities: 6.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -8834,6 +9447,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -8849,6 +9466,8 @@ snapshots: indent-string@4.0.0: {} + inherits@2.0.4: {} + input-format@0.3.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: prop-types: 15.8.1 @@ -8862,6 +9481,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -8938,6 +9559,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8996,6 +9619,8 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jiti@1.21.7: {} + jiti@2.6.1: {} jju@1.4.0: {} @@ -9132,6 +9757,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9176,6 +9805,12 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -9183,10 +9818,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + min-indent@1.0.1: {} minimatch@10.2.6: @@ -9209,12 +9850,22 @@ snapshots: ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} nanoid@3.3.19: {} natural-compare@1.4.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + next@16.3.4(@types/node@20.19.24)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.3.4 @@ -9258,6 +9909,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -9296,6 +9949,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9350,6 +10007,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9361,6 +10020,8 @@ snapshots: lru-cache: 11.1.0 minipass: 7.1.2 + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -9407,8 +10068,43 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 3.1.0 + pirates@4.0.7: {} + possible-typed-array-names@1.1.0: {} + postcss-import@15.1.0(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + postcss-value-parser: 4.2.0 + read-cache: 1.0.2 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.23): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.23 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23)(tsx@4.20.6)(yaml@2.9.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.23 + tsx: 4.20.6 + yaml: 2.9.1 + + postcss-nested@6.2.0(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + postcss@8.5.23: dependencies: nanoid: 3.3.19 @@ -9441,6 +10137,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -9452,12 +10153,28 @@ snapshots: dependencies: hookified: 2.2.0 + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} rambda@7.5.0: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-day-picker@8.10.1(date-fns@4.1.0)(react@19.2.0): dependencies: date-fns: 4.1.0 @@ -9523,6 +10240,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-router@7.18.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + cookie: 1.1.1 + react: 19.2.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.0): dependencies: get-nonce: 1.0.1 @@ -9533,6 +10258,8 @@ snapshots: react@19.2.0: {} + read-cache@1.0.2: {} + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -9578,12 +10305,21 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.5: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + reusify@1.1.0: {} + rimraf@6.0.1: dependencies: glob: 11.0.3 @@ -9657,8 +10393,22 @@ snapshots: rou3@0.7.8: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -9696,12 +10446,39 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2 seroval@1.3.2: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -9724,6 +10501,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + sharp@0.35.4(@types/node@20.19.24): dependencies: '@img/colour': 1.1.0 @@ -9771,6 +10550,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -9794,6 +10578,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -9827,6 +10619,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: @@ -9917,6 +10711,16 @@ snapshots: client-only: 0.0.1 react: 19.2.0 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} @@ -9925,12 +10729,48 @@ snapshots: tailwind-merge@3.3.1: {} + tailwindcss@3.4.19(tsx@4.20.6)(yaml@2.9.1): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.23 + postcss-import: 15.1.0(postcss@8.5.23) + postcss-js: 4.1.0(postcss@8.5.23) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23)(tsx@4.20.6)(yaml@2.9.1) + postcss-nested: 6.2.0(postcss@8.5.23) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + tailwindcss@4.1.15: {} tailwindcss@4.1.16: {} tapable@2.3.0: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -9969,6 +10809,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -9985,6 +10827,8 @@ snapshots: dependencies: typescript: 5.9.2 + ts-interface-checker@0.1.13: {} + tsdown@0.15.9(typescript@5.7.3): dependencies: ansis: 4.2.0 @@ -10025,6 +10869,12 @@ snapshots: type-fest@0.20.2: {} + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -10084,6 +10934,8 @@ snapshots: undici@7.16.0: {} + unpipe@1.0.0: {} + unplugin@2.3.10: dependencies: '@jridgewell/remapping': 2.3.5 @@ -10120,6 +10972,10 @@ snapshots: dependencies: react: 19.2.0 + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -10129,6 +10985,22 @@ snapshots: - '@types/react' - '@types/react-dom' + vite@6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.12 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + tsx: 4.20.6 + yaml: 2.9.1 + vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1): dependencies: esbuild: 0.25.11 @@ -10149,6 +11021,29 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1) + vitest@5.0.0(@types/node@22.18.12)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 6.4.1(@types/node@22.18.12)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.18.12 + '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@5.0.0(@types/node@22.18.12)(@vitest/coverage-v8@5.0.0)(jsdom@26.1.0)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3