@reblu/site-next 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/route-handlers.ts","../src/shop/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n const body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n","/**\n * `@reblu/site-next/shop-route-handlers` (REB-829) — Route Handler factories\n * for the cart/checkout write flow (`/api/site/shop/{checkout, validate-cart,\n * apply-discount, check-email, order/[sessionId]}`). Every demo-site route\n * these replace (`app/api/shop/*`) either hand-rolled the SAME proxy\n * boilerplate `./route-handlers`'s `createProxyRoute` already generalizes\n * (checkout/validate-cart/apply-discount/check-email), or called\n * `proxyToBaseApi` directly for the one dynamic-path route (`order/\n * [sessionId]`) that `createProxyRoute` deliberately does not cover (its own\n * docblock: \"a per-request DYNAMIC targetPath... is genuinely out of scope\").\n * This module is the dynamic-path extension `createProxyRoute` named as a\n * gap, kept as its own file (not folded into `./server`, REB-809's filters/\n * collection glue) because it is a distinct concern — a page that only reads\n * shop data never needs these factories, and a cart/checkout integration\n * never needs the filters glue.\n *\n * Server-only, dependency-free (only `createProxyRoute` + the `next/server`\n * type) — same isolation direction as `./route-handlers`/`./shop/server.ts`:\n * this entry must NEVER carry `'use client'` (see `tsup.config.ts`).\n *\n * Mirrors `createProxyRoute`'s own API-key/server-only handling verbatim —\n * `REBLU_API_KEY` is read lazily, server-side, and injected as\n * `X-Reblu-Api-Key` on the upstream fetch only; it never reaches the\n * Response these handlers return to the browser.\n *\n * // app/api/shop/checkout/route.ts\n * export const POST = createShopCheckoutRoute()\n *\n * // app/api/shop/order/[sessionId]/route.ts\n * export const GET = createShopOrderRoute()\n */\nimport type { NextRequest } from 'next/server'\nimport { createProxyRoute, type CreateProxyRouteOptions } from '../route-handlers'\n\n/** Options for a fixed-path write route. `method` is always `'POST'` here. */\nexport type ShopWriteRouteOptions = Omit<CreateProxyRouteOptions, 'method'>\n\n/**\n * `requireJson: true` by default for every write route below: the browser\n * clients these proxy for (the cart/checkout UI) always set\n * `Content-Type: application/json`, and rejecting anything else with a 400\n * BEFORE the upstream fetch is strictly safer than forwarding an\n * unparseable body to base-api. A SITE with a non-JSON caller can still pass\n * `{ requireJson: false }`.\n */\nconst DEFAULT_WRITE_OPTS: ShopWriteRouteOptions = { requireJson: true }\n\n/** `POST /api/site/shop/checkout` — starts a hosted Stripe Checkout session. */\nexport function createShopCheckoutRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/checkout', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** `POST /api/site/shop/validate-cart` — live stock/price re-check before checkout. */\nexport function createShopValidateCartRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/validate-cart', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** `POST /api/site/shop/apply-discount` — validates a discount code against the cart. */\nexport function createShopApplyDiscountRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/apply-discount', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** `POST /api/site/shop/check-email` — whether a guest's email already has an account. */\nexport function createShopCheckEmailRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/check-email', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** Options for the dynamic order-lookup route. `method` is always `'GET'`. */\nexport type ShopOrderRouteOptions = Omit<CreateProxyRouteOptions, 'method'>\n\n/**\n * `GET /api/site/shop/order/[sessionId]` — the one route in this file with a\n * per-request dynamic target path, so it can't be a one-line\n * `createProxyRoute(...)` call like the others: it reads Next's route\n * `params`, builds `/api/site/shop/order/{sessionId}` per request, and\n * delegates everything else (env, timeout, forwarding, error shaping) to\n * `createProxyRoute`.\n */\nexport function createShopOrderRoute(opts: ShopOrderRouteOptions = {}) {\n return async function handler(\n request: NextRequest,\n context: { params: Promise<{ sessionId: string }> },\n ): Promise<Response> {\n const { sessionId } = await context.params\n const targetPath = `/api/site/shop/order/${encodeURIComponent(sessionId)}`\n return createProxyRoute(targetPath, { ...opts, method: 'GET' })(request)\n }\n}\n"],"mappings":"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","names":[]}
@@ -22,6 +22,7 @@ var server_exports = {};
22
22
  __export(server_exports, {
23
23
  buildShopFilterHrefs: () => buildShopFilterHrefs,
24
24
  getShopCollection: () => getShopCollection,
25
+ getShopOrderSummary: () => getShopOrderSummary,
25
26
  parseShopSearchParams: () => parseShopSearchParams,
26
27
  serializeShopParams: () => serializeShopParams,
27
28
  toURLSearchParams: () => toURLSearchParams
@@ -188,10 +189,26 @@ async function getShopCollection(client, params) {
188
189
  }
189
190
  return { products: productsResult.value, facets: facetsResult.value, facetsStatus: "ok" };
190
191
  }
192
+
193
+ // src/shop/order.ts
194
+ function isNotFoundApiError(err) {
195
+ return err instanceof Error && err.name === "ApiError" && err.status === 404;
196
+ }
197
+ async function getShopOrderSummary(client, sessionId) {
198
+ try {
199
+ return await client.shop.order(sessionId);
200
+ } catch (err) {
201
+ if (isNotFoundApiError(err)) {
202
+ return null;
203
+ }
204
+ throw err;
205
+ }
206
+ }
191
207
  // Annotate the CommonJS export names for ESM import in node:
192
208
  0 && (module.exports = {
193
209
  buildShopFilterHrefs,
194
210
  getShopCollection,
211
+ getShopOrderSummary,
195
212
  parseShopSearchParams,
196
213
  serializeShopParams,
197
214
  toURLSearchParams
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shop/server.ts","../src/shop/params.ts","../src/shop/collection.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-server` (REB-809) — the server-safe half of the shop\n * filters glue. Every export here has ZERO React and must NEVER carry\n * `'use client'` — same isolation direction as `./route-handlers` and\n * `./academy-server` (see `tsup.config.ts`). A Server Component (a route's\n * `page.tsx`) calls these directly: parse its own `searchParams` prop,\n * fetch the collection, build hrefs, and render\n * `@reblu/site-blocks`'s `ShopFilters` with NO `onChange` and NO client\n * boundary at all — the exact \"importable from a Server Component\" shape\n * REB-647/REB-809 require. `./shop` re-exports the same pure helpers\n * alongside the `'use client'` navigation hook for a page that wants the\n * client-reactive version instead.\n */\nexport {\n parseShopSearchParams,\n serializeShopParams,\n toURLSearchParams,\n buildShopFilterHrefs,\n type ShopFilterHrefs,\n} from './params'\nexport { getShopCollection, type ShopCollection } from './collection'\n","import type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * REB-809 — URL ↔ `ShopProductsParams` glue for a SITE's shop/collection page.\n *\n * Mirrors base-api's own `parseListParams`\n * (`apps/base-api/src/lib/shop/catalogue-query.ts`) deliberately: the SAME\n * bracket shape (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable\n * for `attr`), the SAME MVP collapse for `taxonomy` (one term slug per axis —\n * a repeat keeps only the first), and the SAME defensive caps on bracket\n * key/value counts. Keeping this a mirror rather than a re-implementation\n * means a URL this module builds is EXACTLY the URL the server will parse\n * back to the same params — there is no second, drifting parser.\n */\n\nconst MAX_Q_LEN = 200\nconst MAX_FILTER_KEYS = 30\nconst MAX_VALUE_LEN = 120\nconst MAX_VALUES_PER_KEY = 30\n/**\n * Mirrors `shopProductListParamsSchema`'s `limit: z.number().int().min(1).max(50)`\n * in `apps/base-api/src/lib/contracts/shop.contract.ts` — keep the two in sync;\n * a caller-side limit above this is server-rejected/clamped anyway, but parsing\n * it through here means a URL this module builds never claims a limit the\n * server will refuse.\n */\nconst MAX_LIMIT = 50\n\nconst SHOP_SORTS = ['price_asc', 'price_desc', 'newest', 'on_sale'] as const\n\nfunction isShopSort(value: string): value is (typeof SHOP_SORTS)[number] {\n return (SHOP_SORTS as readonly string[]).includes(value)\n}\n\n/**\n * Parse a non-negative integer, STRICT digits only — `/^\\d+$/`, so '12abc'\n * and '1.5' are rejected outright rather than `Number.parseInt` silently\n * taking the leading '12'/'1' and discarding the rest as if it were valid\n * input. Named for exactly what it accepts: >= 0, not \"positive\" (which\n * excludes 0) — callers that need >= 1 (page, limit) enforce that bound\n * themselves on top of this.\n */\nfunction nonNegativeInt(raw: string | null): number | undefined {\n if (raw === null || !/^\\d+$/.test(raw)) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isNaN(n) ? undefined : n\n}\n\n/**\n * Copy a (possibly null-prototype) object's own enumerable properties into a\n * fresh PLAIN object — `Object.prototype` in its chain, nothing else.\n *\n * R3-001: this module's internal accumulators use `Object.create(null)` for\n * prototype-pollution safety (see `parseBracketParams`' own doc below), but\n * that safety property must NEVER leak into `ShopProductsParams.taxonomy`/\n * `.attr`, the values a SITE actually receives. React Flight — the\n * serialization React Server Components use to send props across the\n * Server→Client boundary — refuses a non-plain object (`Object.\n * getPrototypeOf(x) !== Object.prototype`) with a hard runtime error. This\n * package's own README tells a Client Component caller to pass\n * `active={params}` to `ShopFilters`' `onChange` mode, so a null-prototype\n * `taxonomy`/`attr` reaching that boundary would crash every SITE that\n * followed the documented pattern. Object spread (`{ ...source }`) always\n * produces a plain object regardless of the source's own prototype, which is\n * exactly the property this needs and `Object.assign({}, source)` also has —\n * spread is used here for clarity.\n */\nfunction toPlainObject<T>(source: Record<string, T>): Record<string, T> {\n return { ...source }\n}\n\n/**\n * Parse `bracket[key]` params into a map, collapsing repeats into arrays.\n *\n * Built on `Object.create(null)` rather than a plain `{}` — a query string is\n * attacker-controlled input, and `taxonomy[constructor]=x` or\n * `taxonomy[toString]=x` against a plain object reads/writes through the\n * INHERITED `Object.prototype` property of that name before this function's\n * own `key in out` / `out[key]` logic ever sees an actual own property,\n * corrupting the key-count bookkeeping and the parsed value for that key. A\n * null-prototype object has no inherited properties at all, so `in` and\n * indexed access only ever see what this loop itself has written — the same\n * defense `JSON.parse` reviewers reach for against prototype-pollution-style\n * bracket keys, applied here to a hand-rolled key/value accumulator.\n *\n * The RETURNED object is converted back to a plain one via\n * {@link toPlainObject} — the null prototype is this function's OWN internal\n * bookkeeping safety, never part of its public output (see that helper's doc\n * for why: React Flight/RSC serialization).\n */\nfunction parseBracketParams(\n searchParams: URLSearchParams,\n prefix: string,\n): Record<string, string | string[]> | undefined {\n const out: Record<string, string | string[]> = Object.create(null)\n const re = new RegExp(`^${prefix}\\\\[(.+)\\\\]$`)\n for (const [rawKey, rawValue] of searchParams.entries()) {\n const match = rawKey.match(re)\n if (!match) continue\n const key = match[1]\n const value = rawValue.trim().slice(0, MAX_VALUE_LEN)\n // REB-827: an empty/whitespace-only value (e.g. a cleared `taxonomy[marca]=`,\n // or a stale/hand-built link) is never a real filter — trim and drop it\n // here, mirroring how `q` is trimmed and dropped when empty, so a key left\n // with no surviving value never enters `out` and never reaches the caller\n // as `{ key: '' }`/`{ key: [] }`.\n if (!value) continue\n if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue\n const existing = out[key]\n if (existing === undefined) out[key] = value\n else if (Array.isArray(existing)) {\n if (existing.length < MAX_VALUES_PER_KEY) existing.push(value)\n } else out[key] = [existing, value]\n }\n return Object.keys(out).length > 0 ? toPlainObject(out) : undefined\n}\n\n/**\n * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`\n * from `next/navigation`, or a plain `new URLSearchParams(record)`) into\n * `ShopProductsParams`. Hostile/unknown params are dropped: only the known\n * scalar keys and the two bracket groups are ever read.\n */\nexport function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams {\n const params: ShopProductsParams = {}\n\n const page = nonNegativeInt(searchParams.get('page'))\n if (page !== undefined && page >= 1) params.page = page\n\n const limit = nonNegativeInt(searchParams.get('limit'))\n if (limit !== undefined && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit\n\n const q = searchParams.get('q')?.trim().slice(0, MAX_Q_LEN)\n if (q) params.q = q\n\n const sort = searchParams.get('sort')\n if (sort !== null && isShopSort(sort)) params.sort = sort\n\n const category = searchParams.get('category')?.trim()\n if (category) params.category = category\n\n let priceMin = nonNegativeInt(searchParams.get('priceMin'))\n let priceMax = nonNegativeInt(searchParams.get('priceMax'))\n // An inverted range (priceMin > priceMax) would otherwise always match zero\n // products server-side — swap rather than pass through a query that can\n // never return a result, or silently drop half of it.\n if (priceMin !== undefined && priceMax !== undefined && priceMin > priceMax) {\n ;[priceMin, priceMax] = [priceMax, priceMin]\n }\n // Only assign when actually present — an explicit `priceMin: undefined` key\n // is never written, so `toStrictEqual`/`toEqual` against a literal object\n // without that key both hold, and a downstream `'priceMin' in params` check\n // reflects reality.\n if (priceMin !== undefined) params.priceMin = priceMin\n if (priceMax !== undefined) params.priceMax = priceMax\n\n const onSale = searchParams.get('onSale')\n if (onSale === 'true' || onSale === '1') params.onSale = true\n\n const taxonomyRaw = parseBracketParams(searchParams, 'taxonomy')\n if (taxonomyRaw) {\n // A taxonomy axis takes a single term slug in MVP — collapse any repeats,\n // exactly like the server does (see module doc). Object.create(null) for\n // the SAME reason as parseBracketParams' own accumulator: `key` is\n // attacker-controlled (it is the bracket-key from the query string), and\n // a plain `{}` here reintroduces the exact bug parseBracketParams just\n // fixed one step downstream — `taxonomy['__proto__'] = value` on a plain\n // object hits the inherited `__proto__` ACCESSOR, which silently no-ops\n // for a non-object value instead of creating an own property, so that\n // key would vanish from the result entirely (not merely mis-set).\n const taxonomy: Record<string, string> = Object.create(null)\n for (const [key, value] of Object.entries(taxonomyRaw)) {\n taxonomy[key] = Array.isArray(value) ? value[0] : value\n }\n // Plain object out, same reason as parseBracketParams' own return value —\n // see toPlainObject's doc (R3-001, React Flight/RSC serialization).\n params.taxonomy = toPlainObject(taxonomy)\n }\n\n const attr = parseBracketParams(searchParams, 'attr')\n if (attr) params.attr = attr\n\n return params\n}\n\n/**\n * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of\n * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so\n * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`\n * for any already-canonical (non-empty-string, in-range) input — that is the\n * property the round-trip tests pin.\n */\nexport function serializeShopParams(params: ShopProductsParams): URLSearchParams {\n const sp = new URLSearchParams()\n\n if (params.page !== undefined) sp.set('page', String(params.page))\n if (params.limit !== undefined) sp.set('limit', String(params.limit))\n if (params.q) sp.set('q', params.q)\n if (params.sort) sp.set('sort', params.sort)\n if (params.category) sp.set('category', params.category)\n if (params.priceMin !== undefined) sp.set('priceMin', String(params.priceMin))\n if (params.priceMax !== undefined) sp.set('priceMax', String(params.priceMax))\n if (params.onSale) sp.set('onSale', 'true')\n\n // REB-827: an empty/whitespace-only value is never a real filter — never\n // emit it, matching what parseShopSearchParams would now drop on the way\n // back in anyway (a caller handing this an already-empty `active` value,\n // e.g. a stale object a SITE built itself, must not see it reach the URL).\n //\n // R3-serialize-trim-nullish-throw (review follow-up): `ShopProductsParams`\n // types these values as plain strings, but a JS caller building this object\n // dynamically very commonly ends up with an actual `undefined`/`null` value\n // at runtime regardless of the type (e.g. `taxonomy: { marca: someOptional }`).\n // `typeof value === 'string'` guards `.trim()` itself — a nullish entry is\n // treated exactly like an empty one (never emitted), not a thrown TypeError.\n if (params.taxonomy) {\n for (const [key, value] of Object.entries(params.taxonomy)) {\n if (typeof value === 'string' && value.trim()) sp.set(`taxonomy[${key}]`, value)\n }\n }\n\n if (params.attr) {\n for (const [key, value] of Object.entries(params.attr)) {\n if (Array.isArray(value)) {\n for (const v of value) if (typeof v === 'string' && v.trim()) sp.append(`attr[${key}]`, v)\n } else if (typeof value === 'string' && value.trim()) {\n sp.set(`attr[${key}]`, value)\n }\n }\n }\n\n return sp\n}\n\n/**\n * Convert Next's `searchParams` route prop —\n * `Record<string, string | string[] | undefined>`, already URL-decoded, with\n * a repeated query key grouped into an array — into a `URLSearchParams` for\n * {@link parseShopSearchParams}.\n *\n * NOT the same as `new URLSearchParams(searchParams)`: the native\n * `URLSearchParams` constructor only accepts `Record<string, string>` —\n * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls\n * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two\n * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently\n * breaks `parseShopSearchParams`'s repeat-count-based array/scalar\n * distinction (see its own module doc) for every multi-value `attr[<key>]`\n * filter — this helper is what actually preserves the repeat.\n */\nexport function toURLSearchParams(\n searchParams: Record<string, string | string[] | undefined>,\n): URLSearchParams {\n const usp = new URLSearchParams()\n for (const [key, value] of Object.entries(searchParams)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n for (const v of value) usp.append(key, v)\n } else {\n usp.append(key, value)\n }\n }\n return usp\n}\n\n/**\n * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept\n * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package\n * dependency on `@reblu/site-blocks` (this package has none today, and this\n * one shape does not warrant adding one). Any object matching this shape is\n * assignable to `ShopFiltersHrefs` by structural typing.\n */\nexport interface ShopFilterHrefs {\n taxonomy: (axisKey: string, value: string | undefined) => string\n attr: (fieldKey: string, value: string) => string\n price: (min: number | undefined, max: number | undefined) => string\n onSale: (nextActive: boolean) => string\n clearAll: () => string\n}\n\n/**\n * Build `ShopFilters`' href set from the CURRENT params and pathname. Every\n * href preserves every OTHER current param (category, q, sort, every other\n * taxonomy/attr filter) and resets pagination (`page`) — a filter change is\n * never silently stuck on page 4 of a now-smaller result set.\n *\n * Pure and framework-agnostic (no `next/navigation`): a Server Component calls\n * it directly with its own `searchParams` prop and route pathname — no\n * `'use client'` boundary required. See `./use-shop-filter-links` for the\n * client-side hook that wraps this with `usePathname`/`useSearchParams`.\n */\nexport function buildShopFilterHrefs(\n current: ShopProductsParams,\n pathname: string,\n): ShopFilterHrefs {\n function hrefFor(patch: Partial<ShopProductsParams>): string {\n // Any filter change resets pagination — the previous page number almost\n // never still makes sense against the new (usually smaller) result set.\n const next: ShopProductsParams = { ...current, ...patch, page: undefined }\n const qs = serializeShopParams(next).toString()\n return qs ? `${pathname}?${qs}` : pathname\n }\n\n return {\n taxonomy: (axisKey, value) => {\n const taxonomy = { ...current.taxonomy }\n if (value === undefined) delete taxonomy[axisKey]\n else taxonomy[axisKey] = value\n return hrefFor({ taxonomy })\n },\n attr: (fieldKey, value) => {\n const existing = current.attr?.[fieldKey]\n const values = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]\n const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value]\n const attr = { ...current.attr }\n if (next.length === 0) delete attr[fieldKey]\n else attr[fieldKey] = next\n return hrefFor({ attr })\n },\n price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),\n onSale: (nextActive) => hrefFor({ onSale: nextActive || undefined }),\n // Clears every FACET dimension only — q/category/sort (the non-facet part\n // of the query) are deliberately preserved, mirroring ShopFilters' own\n // onChange clear-all patch.\n clearAll: () =>\n hrefFor({ taxonomy: undefined, attr: undefined, priceMin: undefined, priceMax: undefined, onSale: undefined }),\n }\n}\n","import type { RebluClient, ListResult } from '@reblu/site-client'\nimport type { ShopFacets, ShopProductListItem } from '@reblu/site-contracts'\nimport type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * `getShopCollection` (REB-809) — fetches the product list AND its facets with\n * the EXACT SAME params in one call. This is the seam that keeps a shop\n * collection page correct: facet counts must reflect the same filter context\n * the product list is showing, and a hand-rolled page that calls\n * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`\n * separately is exactly how they drift apart.\n *\n * Server-safe (no `'use client'`, no React) — call it directly from an async\n * Server Component (route `page.tsx`) with the params parsed via\n * {@link parseShopSearchParams} from that page's `searchParams` prop.\n */\nexport interface ShopCollection {\n products: ListResult<ShopProductListItem>\n facets: ShopFacets\n /**\n * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;\n * `'unavailable'` when it failed and `facets` above is the degraded\n * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an\n * empty `facets` as \"this tenant configured no filterable axes\" when it may\n * really mean \"the facets endpoint is down right now\". A logged\n * `console.warn` (see below) covers the case nobody checks this field.\n */\n facetsStatus: 'ok' | 'unavailable'\n}\n\n/**\n * Returned when `client.shop.facets(...)` fails — a `ShopFilters` that\n * receives this simply renders nothing (`shouldRenderShopFilters` is false\n * for it with no active selection), which is the correct degraded state: no\n * filter UI, rather than no page at all. Paired with `facetsStatus:\n * 'unavailable'` on the returned {@link ShopCollection}, so a caller that\n * DOES care can tell \"no axes configured\" apart from \"facets endpoint down\".\n */\nconst EMPTY_FACETS: ShopFacets = {\n taxonomies: [],\n attributes: [],\n price: { min: 0, max: 0 },\n onSale: { count: 0 },\n}\n\n/**\n * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT\n * equally critical. A product listing with no products is not a listing —\n * that failure must propagate so the caller's own error handling (or a\n * thrown-away `try/catch` degrading the whole page) sees it. A product\n * listing with no FACETS is still a perfectly good listing, just without\n * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead\n * of letting one upstream endpoint's outage take the other down too, is what\n * `Promise.allSettled` (rather than `Promise.all`) buys here.\n *\n * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged\n * `[getShopCollection]` — the same convention `@reblu/site-next` already uses\n * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,\n * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the\n * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on\n * it (retry, surface a banner, alert) do so without parsing a log line.\n */\nexport async function getShopCollection(\n client: RebluClient,\n params?: ShopProductsParams,\n): Promise<ShopCollection> {\n const [productsResult, facetsResult] = await Promise.allSettled([\n client.shop.products(params),\n client.shop.facets(params),\n ])\n\n if (productsResult.status === 'rejected') {\n throw productsResult.reason\n }\n\n if (facetsResult.status === 'rejected') {\n console.warn('[getShopCollection] facets fetch failed, degrading to empty ShopFacets:', facetsResult.reason)\n return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: 'unavailable' }\n }\n\n return { products: productsResult.value, facets: facetsResult.value, facetsStatus: 'ok' }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/shop/server.ts","../src/shop/params.ts","../src/shop/collection.ts","../src/shop/order.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-server` (REB-809) — the server-safe half of the shop\n * filters glue. Every export here has ZERO React and must NEVER carry\n * `'use client'` — same isolation direction as `./route-handlers` and\n * `./academy-server` (see `tsup.config.ts`). A Server Component (a route's\n * `page.tsx`) calls these directly: parse its own `searchParams` prop,\n * fetch the collection, build hrefs, and render\n * `@reblu/site-blocks`'s `ShopFilters` with NO `onChange` and NO client\n * boundary at all — the exact \"importable from a Server Component\" shape\n * REB-647/REB-809 require. `./shop` re-exports the same pure helpers\n * alongside the `'use client'` navigation hook for a page that wants the\n * client-reactive version instead.\n */\nexport {\n parseShopSearchParams,\n serializeShopParams,\n toURLSearchParams,\n buildShopFilterHrefs,\n type ShopFilterHrefs,\n} from './params'\nexport { getShopCollection, type ShopCollection } from './collection'\n// REB-829 — order-confirmation server helper. Same server-safe shape as\n// `getShopCollection` above (takes a caller-built `RebluClient`).\nexport { getShopOrderSummary } from './order'\n","import type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * REB-809 — URL ↔ `ShopProductsParams` glue for a SITE's shop/collection page.\n *\n * Mirrors base-api's own `parseListParams`\n * (`apps/base-api/src/lib/shop/catalogue-query.ts`) deliberately: the SAME\n * bracket shape (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable\n * for `attr`), the SAME MVP collapse for `taxonomy` (one term slug per axis —\n * a repeat keeps only the first), and the SAME defensive caps on bracket\n * key/value counts. Keeping this a mirror rather than a re-implementation\n * means a URL this module builds is EXACTLY the URL the server will parse\n * back to the same params — there is no second, drifting parser.\n */\n\nconst MAX_Q_LEN = 200\nconst MAX_FILTER_KEYS = 30\nconst MAX_VALUE_LEN = 120\nconst MAX_VALUES_PER_KEY = 30\n/**\n * Mirrors `shopProductListParamsSchema`'s `limit: z.number().int().min(1).max(50)`\n * in `apps/base-api/src/lib/contracts/shop.contract.ts` — keep the two in sync;\n * a caller-side limit above this is server-rejected/clamped anyway, but parsing\n * it through here means a URL this module builds never claims a limit the\n * server will refuse.\n */\nconst MAX_LIMIT = 50\n\nconst SHOP_SORTS = ['price_asc', 'price_desc', 'newest', 'on_sale'] as const\n\nfunction isShopSort(value: string): value is (typeof SHOP_SORTS)[number] {\n return (SHOP_SORTS as readonly string[]).includes(value)\n}\n\n/**\n * Parse a non-negative integer, STRICT digits only — `/^\\d+$/`, so '12abc'\n * and '1.5' are rejected outright rather than `Number.parseInt` silently\n * taking the leading '12'/'1' and discarding the rest as if it were valid\n * input. Named for exactly what it accepts: >= 0, not \"positive\" (which\n * excludes 0) — callers that need >= 1 (page, limit) enforce that bound\n * themselves on top of this.\n */\nfunction nonNegativeInt(raw: string | null): number | undefined {\n if (raw === null || !/^\\d+$/.test(raw)) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isNaN(n) ? undefined : n\n}\n\n/**\n * Copy a (possibly null-prototype) object's own enumerable properties into a\n * fresh PLAIN object — `Object.prototype` in its chain, nothing else.\n *\n * R3-001: this module's internal accumulators use `Object.create(null)` for\n * prototype-pollution safety (see `parseBracketParams`' own doc below), but\n * that safety property must NEVER leak into `ShopProductsParams.taxonomy`/\n * `.attr`, the values a SITE actually receives. React Flight — the\n * serialization React Server Components use to send props across the\n * Server→Client boundary — refuses a non-plain object (`Object.\n * getPrototypeOf(x) !== Object.prototype`) with a hard runtime error. This\n * package's own README tells a Client Component caller to pass\n * `active={params}` to `ShopFilters`' `onChange` mode, so a null-prototype\n * `taxonomy`/`attr` reaching that boundary would crash every SITE that\n * followed the documented pattern. Object spread (`{ ...source }`) always\n * produces a plain object regardless of the source's own prototype, which is\n * exactly the property this needs and `Object.assign({}, source)` also has —\n * spread is used here for clarity.\n */\nfunction toPlainObject<T>(source: Record<string, T>): Record<string, T> {\n return { ...source }\n}\n\n/**\n * Parse `bracket[key]` params into a map, collapsing repeats into arrays.\n *\n * Built on `Object.create(null)` rather than a plain `{}` — a query string is\n * attacker-controlled input, and `taxonomy[constructor]=x` or\n * `taxonomy[toString]=x` against a plain object reads/writes through the\n * INHERITED `Object.prototype` property of that name before this function's\n * own `key in out` / `out[key]` logic ever sees an actual own property,\n * corrupting the key-count bookkeeping and the parsed value for that key. A\n * null-prototype object has no inherited properties at all, so `in` and\n * indexed access only ever see what this loop itself has written — the same\n * defense `JSON.parse` reviewers reach for against prototype-pollution-style\n * bracket keys, applied here to a hand-rolled key/value accumulator.\n *\n * The RETURNED object is converted back to a plain one via\n * {@link toPlainObject} — the null prototype is this function's OWN internal\n * bookkeeping safety, never part of its public output (see that helper's doc\n * for why: React Flight/RSC serialization).\n */\nfunction parseBracketParams(\n searchParams: URLSearchParams,\n prefix: string,\n): Record<string, string | string[]> | undefined {\n const out: Record<string, string | string[]> = Object.create(null)\n const re = new RegExp(`^${prefix}\\\\[(.+)\\\\]$`)\n for (const [rawKey, rawValue] of searchParams.entries()) {\n const match = rawKey.match(re)\n if (!match) continue\n const key = match[1]\n const value = rawValue.trim().slice(0, MAX_VALUE_LEN)\n // REB-827: an empty/whitespace-only value (e.g. a cleared `taxonomy[marca]=`,\n // or a stale/hand-built link) is never a real filter — trim and drop it\n // here, mirroring how `q` is trimmed and dropped when empty, so a key left\n // with no surviving value never enters `out` and never reaches the caller\n // as `{ key: '' }`/`{ key: [] }`.\n if (!value) continue\n if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue\n const existing = out[key]\n if (existing === undefined) out[key] = value\n else if (Array.isArray(existing)) {\n if (existing.length < MAX_VALUES_PER_KEY) existing.push(value)\n } else out[key] = [existing, value]\n }\n return Object.keys(out).length > 0 ? toPlainObject(out) : undefined\n}\n\n/**\n * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`\n * from `next/navigation`, or a plain `new URLSearchParams(record)`) into\n * `ShopProductsParams`. Hostile/unknown params are dropped: only the known\n * scalar keys and the two bracket groups are ever read.\n */\nexport function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams {\n const params: ShopProductsParams = {}\n\n const page = nonNegativeInt(searchParams.get('page'))\n if (page !== undefined && page >= 1) params.page = page\n\n const limit = nonNegativeInt(searchParams.get('limit'))\n if (limit !== undefined && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit\n\n const q = searchParams.get('q')?.trim().slice(0, MAX_Q_LEN)\n if (q) params.q = q\n\n const sort = searchParams.get('sort')\n if (sort !== null && isShopSort(sort)) params.sort = sort\n\n const category = searchParams.get('category')?.trim()\n if (category) params.category = category\n\n let priceMin = nonNegativeInt(searchParams.get('priceMin'))\n let priceMax = nonNegativeInt(searchParams.get('priceMax'))\n // An inverted range (priceMin > priceMax) would otherwise always match zero\n // products server-side — swap rather than pass through a query that can\n // never return a result, or silently drop half of it.\n if (priceMin !== undefined && priceMax !== undefined && priceMin > priceMax) {\n ;[priceMin, priceMax] = [priceMax, priceMin]\n }\n // Only assign when actually present — an explicit `priceMin: undefined` key\n // is never written, so `toStrictEqual`/`toEqual` against a literal object\n // without that key both hold, and a downstream `'priceMin' in params` check\n // reflects reality.\n if (priceMin !== undefined) params.priceMin = priceMin\n if (priceMax !== undefined) params.priceMax = priceMax\n\n const onSale = searchParams.get('onSale')\n if (onSale === 'true' || onSale === '1') params.onSale = true\n\n const taxonomyRaw = parseBracketParams(searchParams, 'taxonomy')\n if (taxonomyRaw) {\n // A taxonomy axis takes a single term slug in MVP — collapse any repeats,\n // exactly like the server does (see module doc). Object.create(null) for\n // the SAME reason as parseBracketParams' own accumulator: `key` is\n // attacker-controlled (it is the bracket-key from the query string), and\n // a plain `{}` here reintroduces the exact bug parseBracketParams just\n // fixed one step downstream — `taxonomy['__proto__'] = value` on a plain\n // object hits the inherited `__proto__` ACCESSOR, which silently no-ops\n // for a non-object value instead of creating an own property, so that\n // key would vanish from the result entirely (not merely mis-set).\n const taxonomy: Record<string, string> = Object.create(null)\n for (const [key, value] of Object.entries(taxonomyRaw)) {\n taxonomy[key] = Array.isArray(value) ? value[0] : value\n }\n // Plain object out, same reason as parseBracketParams' own return value —\n // see toPlainObject's doc (R3-001, React Flight/RSC serialization).\n params.taxonomy = toPlainObject(taxonomy)\n }\n\n const attr = parseBracketParams(searchParams, 'attr')\n if (attr) params.attr = attr\n\n return params\n}\n\n/**\n * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of\n * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so\n * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`\n * for any already-canonical (non-empty-string, in-range) input — that is the\n * property the round-trip tests pin.\n */\nexport function serializeShopParams(params: ShopProductsParams): URLSearchParams {\n const sp = new URLSearchParams()\n\n if (params.page !== undefined) sp.set('page', String(params.page))\n if (params.limit !== undefined) sp.set('limit', String(params.limit))\n if (params.q) sp.set('q', params.q)\n if (params.sort) sp.set('sort', params.sort)\n if (params.category) sp.set('category', params.category)\n if (params.priceMin !== undefined) sp.set('priceMin', String(params.priceMin))\n if (params.priceMax !== undefined) sp.set('priceMax', String(params.priceMax))\n if (params.onSale) sp.set('onSale', 'true')\n\n // REB-827: an empty/whitespace-only value is never a real filter — never\n // emit it, matching what parseShopSearchParams would now drop on the way\n // back in anyway (a caller handing this an already-empty `active` value,\n // e.g. a stale object a SITE built itself, must not see it reach the URL).\n //\n // R3-serialize-trim-nullish-throw (review follow-up): `ShopProductsParams`\n // types these values as plain strings, but a JS caller building this object\n // dynamically very commonly ends up with an actual `undefined`/`null` value\n // at runtime regardless of the type (e.g. `taxonomy: { marca: someOptional }`).\n // `typeof value === 'string'` guards `.trim()` itself — a nullish entry is\n // treated exactly like an empty one (never emitted), not a thrown TypeError.\n if (params.taxonomy) {\n for (const [key, value] of Object.entries(params.taxonomy)) {\n if (typeof value === 'string' && value.trim()) sp.set(`taxonomy[${key}]`, value)\n }\n }\n\n if (params.attr) {\n for (const [key, value] of Object.entries(params.attr)) {\n if (Array.isArray(value)) {\n for (const v of value) if (typeof v === 'string' && v.trim()) sp.append(`attr[${key}]`, v)\n } else if (typeof value === 'string' && value.trim()) {\n sp.set(`attr[${key}]`, value)\n }\n }\n }\n\n return sp\n}\n\n/**\n * Convert Next's `searchParams` route prop —\n * `Record<string, string | string[] | undefined>`, already URL-decoded, with\n * a repeated query key grouped into an array — into a `URLSearchParams` for\n * {@link parseShopSearchParams}.\n *\n * NOT the same as `new URLSearchParams(searchParams)`: the native\n * `URLSearchParams` constructor only accepts `Record<string, string>` —\n * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls\n * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two\n * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently\n * breaks `parseShopSearchParams`'s repeat-count-based array/scalar\n * distinction (see its own module doc) for every multi-value `attr[<key>]`\n * filter — this helper is what actually preserves the repeat.\n */\nexport function toURLSearchParams(\n searchParams: Record<string, string | string[] | undefined>,\n): URLSearchParams {\n const usp = new URLSearchParams()\n for (const [key, value] of Object.entries(searchParams)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n for (const v of value) usp.append(key, v)\n } else {\n usp.append(key, value)\n }\n }\n return usp\n}\n\n/**\n * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept\n * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package\n * dependency on `@reblu/site-blocks` (this package has none today, and this\n * one shape does not warrant adding one). Any object matching this shape is\n * assignable to `ShopFiltersHrefs` by structural typing.\n */\nexport interface ShopFilterHrefs {\n taxonomy: (axisKey: string, value: string | undefined) => string\n attr: (fieldKey: string, value: string) => string\n price: (min: number | undefined, max: number | undefined) => string\n onSale: (nextActive: boolean) => string\n clearAll: () => string\n}\n\n/**\n * Build `ShopFilters`' href set from the CURRENT params and pathname. Every\n * href preserves every OTHER current param (category, q, sort, every other\n * taxonomy/attr filter) and resets pagination (`page`) — a filter change is\n * never silently stuck on page 4 of a now-smaller result set.\n *\n * Pure and framework-agnostic (no `next/navigation`): a Server Component calls\n * it directly with its own `searchParams` prop and route pathname — no\n * `'use client'` boundary required. See `./use-shop-filter-links` for the\n * client-side hook that wraps this with `usePathname`/`useSearchParams`.\n */\nexport function buildShopFilterHrefs(\n current: ShopProductsParams,\n pathname: string,\n): ShopFilterHrefs {\n function hrefFor(patch: Partial<ShopProductsParams>): string {\n // Any filter change resets pagination — the previous page number almost\n // never still makes sense against the new (usually smaller) result set.\n const next: ShopProductsParams = { ...current, ...patch, page: undefined }\n const qs = serializeShopParams(next).toString()\n return qs ? `${pathname}?${qs}` : pathname\n }\n\n return {\n taxonomy: (axisKey, value) => {\n const taxonomy = { ...current.taxonomy }\n if (value === undefined) delete taxonomy[axisKey]\n else taxonomy[axisKey] = value\n return hrefFor({ taxonomy })\n },\n attr: (fieldKey, value) => {\n const existing = current.attr?.[fieldKey]\n const values = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]\n const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value]\n const attr = { ...current.attr }\n if (next.length === 0) delete attr[fieldKey]\n else attr[fieldKey] = next\n return hrefFor({ attr })\n },\n price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),\n onSale: (nextActive) => hrefFor({ onSale: nextActive || undefined }),\n // Clears every FACET dimension only — q/category/sort (the non-facet part\n // of the query) are deliberately preserved, mirroring ShopFilters' own\n // onChange clear-all patch.\n clearAll: () =>\n hrefFor({ taxonomy: undefined, attr: undefined, priceMin: undefined, priceMax: undefined, onSale: undefined }),\n }\n}\n","import type { RebluClient, ListResult } from '@reblu/site-client'\nimport type { ShopFacets, ShopProductListItem } from '@reblu/site-contracts'\nimport type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * `getShopCollection` (REB-809) — fetches the product list AND its facets with\n * the EXACT SAME params in one call. This is the seam that keeps a shop\n * collection page correct: facet counts must reflect the same filter context\n * the product list is showing, and a hand-rolled page that calls\n * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`\n * separately is exactly how they drift apart.\n *\n * Server-safe (no `'use client'`, no React) — call it directly from an async\n * Server Component (route `page.tsx`) with the params parsed via\n * {@link parseShopSearchParams} from that page's `searchParams` prop.\n */\nexport interface ShopCollection {\n products: ListResult<ShopProductListItem>\n facets: ShopFacets\n /**\n * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;\n * `'unavailable'` when it failed and `facets` above is the degraded\n * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an\n * empty `facets` as \"this tenant configured no filterable axes\" when it may\n * really mean \"the facets endpoint is down right now\". A logged\n * `console.warn` (see below) covers the case nobody checks this field.\n */\n facetsStatus: 'ok' | 'unavailable'\n}\n\n/**\n * Returned when `client.shop.facets(...)` fails — a `ShopFilters` that\n * receives this simply renders nothing (`shouldRenderShopFilters` is false\n * for it with no active selection), which is the correct degraded state: no\n * filter UI, rather than no page at all. Paired with `facetsStatus:\n * 'unavailable'` on the returned {@link ShopCollection}, so a caller that\n * DOES care can tell \"no axes configured\" apart from \"facets endpoint down\".\n */\nconst EMPTY_FACETS: ShopFacets = {\n taxonomies: [],\n attributes: [],\n price: { min: 0, max: 0 },\n onSale: { count: 0 },\n}\n\n/**\n * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT\n * equally critical. A product listing with no products is not a listing —\n * that failure must propagate so the caller's own error handling (or a\n * thrown-away `try/catch` degrading the whole page) sees it. A product\n * listing with no FACETS is still a perfectly good listing, just without\n * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead\n * of letting one upstream endpoint's outage take the other down too, is what\n * `Promise.allSettled` (rather than `Promise.all`) buys here.\n *\n * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged\n * `[getShopCollection]` — the same convention `@reblu/site-next` already uses\n * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,\n * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the\n * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on\n * it (retry, surface a banner, alert) do so without parsing a log line.\n */\nexport async function getShopCollection(\n client: RebluClient,\n params?: ShopProductsParams,\n): Promise<ShopCollection> {\n const [productsResult, facetsResult] = await Promise.allSettled([\n client.shop.products(params),\n client.shop.facets(params),\n ])\n\n if (productsResult.status === 'rejected') {\n throw productsResult.reason\n }\n\n if (facetsResult.status === 'rejected') {\n console.warn('[getShopCollection] facets fetch failed, degrading to empty ShopFacets:', facetsResult.reason)\n return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: 'unavailable' }\n }\n\n return { products: productsResult.value, facets: facetsResult.value, facetsStatus: 'ok' }\n}\n","import type { RebluClient } from '@reblu/site-client'\nimport type { ShopOrder } from '@reblu/site-contracts'\n\n/**\n * Duck-types `ApiError` (`@reblu/site-client`'s `name = 'ApiError'`, `status:\n * number`) instead of `instanceof ApiError` — importing the class as a VALUE\n * would give this file a real runtime `@reblu/site-client` import, breaking\n * `./shop-server`'s zero-runtime-dependency contract (`tests/\n * shop-subpath-isolation.test.ts`: \"shop-server emits zero runtime import\n * specifiers\"). `RebluClient` above stays a type-only import, erased at\n * build — this is the only runtime-observable check this file may perform.\n */\nfunction isNotFoundApiError(err: unknown): boolean {\n return (\n err instanceof Error &&\n err.name === 'ApiError' &&\n (err as { status?: unknown }).status === 404\n )\n}\n\n/**\n * `getShopOrderSummary` (REB-829) — the server helper for an order-\n * confirmation page. Server-safe (no `'use client'`, no React) — same shape\n * as `getShopCollection` in `./collection`: it takes a `RebluClient` the\n * caller already built (Server Component or Route Handler, where the API key\n * can live), never constructs its own.\n *\n * Degrades a 404 to `null` instead of throwing: right after a Stripe\n * redirect the order-confirming webhook may not have landed yet, so \"not\n * found YET\" is an expected, poll-again state for this one lookup — not a\n * fault. Any other failure (5xx, network) propagates, same as\n * `getShopCollection`'s `products` leg: a caller that wants to show a\n * generic \"something went wrong\" page still can, but this helper does not\n * paper over it as \"order not found\".\n */\nexport async function getShopOrderSummary(\n client: RebluClient,\n sessionId: string,\n): Promise<ShopOrder | null> {\n try {\n return await client.shop.order(sessionId)\n } catch (err) {\n if (isNotFoundApiError(err)) {\n return null\n }\n throw err\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,106 +1,22 @@
1
- import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
- import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
1
+ export { a as ShopCollection, S as ShopFilterHrefs, b as buildShopFilterHrefs, g as getShopCollection, p as parseShopSearchParams, s as serializeShopParams, t as toURLSearchParams } from './collection-BE5dhf-r.cjs';
2
+ import { RebluClient } from '@reblu/site-client';
3
+ import { ShopOrder } from '@reblu/site-contracts';
3
4
 
4
5
  /**
5
- * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
- * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
- * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
- * scalar keys and the two bracket groups are ever read.
9
- */
10
- declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
- /**
12
- * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
- * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
- * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
- * for any already-canonical (non-empty-string, in-range) input — that is the
16
- * property the round-trip tests pin.
17
- */
18
- declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
- /**
20
- * Convert Next's `searchParams` route prop —
21
- * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
- * a repeated query key grouped into an array — into a `URLSearchParams` for
23
- * {@link parseShopSearchParams}.
24
- *
25
- * NOT the same as `new URLSearchParams(searchParams)`: the native
26
- * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
- * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
- * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
- * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
- * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
- * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
- * filter — this helper is what actually preserves the repeat.
33
- */
34
- declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
- /**
36
- * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
- * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
- * dependency on `@reblu/site-blocks` (this package has none today, and this
39
- * one shape does not warrant adding one). Any object matching this shape is
40
- * assignable to `ShopFiltersHrefs` by structural typing.
41
- */
42
- interface ShopFilterHrefs {
43
- taxonomy: (axisKey: string, value: string | undefined) => string;
44
- attr: (fieldKey: string, value: string) => string;
45
- price: (min: number | undefined, max: number | undefined) => string;
46
- onSale: (nextActive: boolean) => string;
47
- clearAll: () => string;
48
- }
49
- /**
50
- * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
- * href preserves every OTHER current param (category, q, sort, every other
52
- * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
- * never silently stuck on page 4 of a now-smaller result set.
54
- *
55
- * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
- * it directly with its own `searchParams` prop and route pathname — no
57
- * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
- * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
- */
60
- declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
-
62
- /**
63
- * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
- * the EXACT SAME params in one call. This is the seam that keeps a shop
65
- * collection page correct: facet counts must reflect the same filter context
66
- * the product list is showing, and a hand-rolled page that calls
67
- * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
- * separately is exactly how they drift apart.
69
- *
70
- * Server-safe (no `'use client'`, no React) — call it directly from an async
71
- * Server Component (route `page.tsx`) with the params parsed via
72
- * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
- */
74
- interface ShopCollection {
75
- products: ListResult<ShopProductListItem>;
76
- facets: ShopFacets;
77
- /**
78
- * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
- * `'unavailable'` when it failed and `facets` above is the degraded
80
- * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
- * empty `facets` as "this tenant configured no filterable axes" when it may
82
- * really mean "the facets endpoint is down right now". A logged
83
- * `console.warn` (see below) covers the case nobody checks this field.
84
- */
85
- facetsStatus: 'ok' | 'unavailable';
86
- }
87
- /**
88
- * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
- * equally critical. A product listing with no products is not a listing —
90
- * that failure must propagate so the caller's own error handling (or a
91
- * thrown-away `try/catch` degrading the whole page) sees it. A product
92
- * listing with no FACETS is still a perfectly good listing, just without
93
- * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
- * of letting one upstream endpoint's outage take the other down too, is what
95
- * `Promise.allSettled` (rather than `Promise.all`) buys here.
6
+ * `getShopOrderSummary` (REB-829) — the server helper for an order-
7
+ * confirmation page. Server-safe (no `'use client'`, no React) — same shape
8
+ * as `getShopCollection` in `./collection`: it takes a `RebluClient` the
9
+ * caller already built (Server Component or Route Handler, where the API key
10
+ * can live), never constructs its own.
96
11
  *
97
- * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
- * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
- * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
- * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
- * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
- * it (retry, surface a banner, alert) do so without parsing a log line.
12
+ * Degrades a 404 to `null` instead of throwing: right after a Stripe
13
+ * redirect the order-confirming webhook may not have landed yet, so "not
14
+ * found YET" is an expected, poll-again state for this one lookup — not a
15
+ * fault. Any other failure (5xx, network) propagates, same as
16
+ * `getShopCollection`'s `products` leg: a caller that wants to show a
17
+ * generic "something went wrong" page still can, but this helper does not
18
+ * paper over it as "order not found".
103
19
  */
104
- declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
20
+ declare function getShopOrderSummary(client: RebluClient, sessionId: string): Promise<ShopOrder | null>;
105
21
 
106
- export { type ShopCollection, type ShopFilterHrefs, buildShopFilterHrefs, getShopCollection, parseShopSearchParams, serializeShopParams, toURLSearchParams };
22
+ export { getShopOrderSummary };
@@ -1,106 +1,22 @@
1
- import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
- import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
1
+ export { a as ShopCollection, S as ShopFilterHrefs, b as buildShopFilterHrefs, g as getShopCollection, p as parseShopSearchParams, s as serializeShopParams, t as toURLSearchParams } from './collection-BE5dhf-r.js';
2
+ import { RebluClient } from '@reblu/site-client';
3
+ import { ShopOrder } from '@reblu/site-contracts';
3
4
 
4
5
  /**
5
- * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
- * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
- * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
- * scalar keys and the two bracket groups are ever read.
9
- */
10
- declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
- /**
12
- * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
- * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
- * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
- * for any already-canonical (non-empty-string, in-range) input — that is the
16
- * property the round-trip tests pin.
17
- */
18
- declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
- /**
20
- * Convert Next's `searchParams` route prop —
21
- * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
- * a repeated query key grouped into an array — into a `URLSearchParams` for
23
- * {@link parseShopSearchParams}.
24
- *
25
- * NOT the same as `new URLSearchParams(searchParams)`: the native
26
- * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
- * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
- * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
- * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
- * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
- * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
- * filter — this helper is what actually preserves the repeat.
33
- */
34
- declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
- /**
36
- * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
- * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
- * dependency on `@reblu/site-blocks` (this package has none today, and this
39
- * one shape does not warrant adding one). Any object matching this shape is
40
- * assignable to `ShopFiltersHrefs` by structural typing.
41
- */
42
- interface ShopFilterHrefs {
43
- taxonomy: (axisKey: string, value: string | undefined) => string;
44
- attr: (fieldKey: string, value: string) => string;
45
- price: (min: number | undefined, max: number | undefined) => string;
46
- onSale: (nextActive: boolean) => string;
47
- clearAll: () => string;
48
- }
49
- /**
50
- * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
- * href preserves every OTHER current param (category, q, sort, every other
52
- * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
- * never silently stuck on page 4 of a now-smaller result set.
54
- *
55
- * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
- * it directly with its own `searchParams` prop and route pathname — no
57
- * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
- * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
- */
60
- declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
-
62
- /**
63
- * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
- * the EXACT SAME params in one call. This is the seam that keeps a shop
65
- * collection page correct: facet counts must reflect the same filter context
66
- * the product list is showing, and a hand-rolled page that calls
67
- * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
- * separately is exactly how they drift apart.
69
- *
70
- * Server-safe (no `'use client'`, no React) — call it directly from an async
71
- * Server Component (route `page.tsx`) with the params parsed via
72
- * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
- */
74
- interface ShopCollection {
75
- products: ListResult<ShopProductListItem>;
76
- facets: ShopFacets;
77
- /**
78
- * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
- * `'unavailable'` when it failed and `facets` above is the degraded
80
- * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
- * empty `facets` as "this tenant configured no filterable axes" when it may
82
- * really mean "the facets endpoint is down right now". A logged
83
- * `console.warn` (see below) covers the case nobody checks this field.
84
- */
85
- facetsStatus: 'ok' | 'unavailable';
86
- }
87
- /**
88
- * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
- * equally critical. A product listing with no products is not a listing —
90
- * that failure must propagate so the caller's own error handling (or a
91
- * thrown-away `try/catch` degrading the whole page) sees it. A product
92
- * listing with no FACETS is still a perfectly good listing, just without
93
- * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
- * of letting one upstream endpoint's outage take the other down too, is what
95
- * `Promise.allSettled` (rather than `Promise.all`) buys here.
6
+ * `getShopOrderSummary` (REB-829) — the server helper for an order-
7
+ * confirmation page. Server-safe (no `'use client'`, no React) — same shape
8
+ * as `getShopCollection` in `./collection`: it takes a `RebluClient` the
9
+ * caller already built (Server Component or Route Handler, where the API key
10
+ * can live), never constructs its own.
96
11
  *
97
- * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
- * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
- * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
- * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
- * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
- * it (retry, surface a banner, alert) do so without parsing a log line.
12
+ * Degrades a 404 to `null` instead of throwing: right after a Stripe
13
+ * redirect the order-confirming webhook may not have landed yet, so "not
14
+ * found YET" is an expected, poll-again state for this one lookup — not a
15
+ * fault. Any other failure (5xx, network) propagates, same as
16
+ * `getShopCollection`'s `products` leg: a caller that wants to show a
17
+ * generic "something went wrong" page still can, but this helper does not
18
+ * paper over it as "order not found".
103
19
  */
104
- declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
20
+ declare function getShopOrderSummary(client: RebluClient, sessionId: string): Promise<ShopOrder | null>;
105
21
 
106
- export { type ShopCollection, type ShopFilterHrefs, buildShopFilterHrefs, getShopCollection, parseShopSearchParams, serializeShopParams, toURLSearchParams };
22
+ export { getShopOrderSummary };
@@ -158,9 +158,25 @@ async function getShopCollection(client, params) {
158
158
  }
159
159
  return { products: productsResult.value, facets: facetsResult.value, facetsStatus: "ok" };
160
160
  }
161
+
162
+ // src/shop/order.ts
163
+ function isNotFoundApiError(err) {
164
+ return err instanceof Error && err.name === "ApiError" && err.status === 404;
165
+ }
166
+ async function getShopOrderSummary(client, sessionId) {
167
+ try {
168
+ return await client.shop.order(sessionId);
169
+ } catch (err) {
170
+ if (isNotFoundApiError(err)) {
171
+ return null;
172
+ }
173
+ throw err;
174
+ }
175
+ }
161
176
  export {
162
177
  buildShopFilterHrefs,
163
178
  getShopCollection,
179
+ getShopOrderSummary,
164
180
  parseShopSearchParams,
165
181
  serializeShopParams,
166
182
  toURLSearchParams