@doswiftly/storefront-sdk 15.1.0 → 16.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,374 @@
1
1
  # Changelog
2
2
 
3
+ ## 16.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1e6062e: Read the gift card recipient back from the cart.
8
+
9
+ `cartUpdateGiftCardRecipient` already persisted the recipient (email, name,
10
+ personalised message) on a gift card line — but the `CartLine` type didn't
11
+ expose any field to read it. Storefronts that render the checkout server-side
12
+ (SSR, deep link, "back to cart" navigation) had no way to know that the buyer
13
+ had already filled in the recipient form on a previous visit. The recipient
14
+ dialog opened blank, like nothing had been saved.
15
+
16
+ **New field** — `CartLine.giftCardRecipient: GiftCardLineRecipient`
17
+
18
+ ```graphql
19
+ type GiftCardLineRecipient {
20
+ recipientEmail: String
21
+ recipientName: String
22
+ message: String
23
+ }
24
+ ```
25
+
26
+ All three fields are individually nullable so a partial set (email only, no
27
+ name or message) is representable as-is. The field on `CartLine` itself is
28
+ nullable — `null` means the buyer has not set a recipient yet, in which case
29
+ the gift card is delivered to the order `customerEmail` on completion.
30
+
31
+ `null` is also what you get for any non-gift-card line — there is no risk of
32
+ the field reporting a stale recipient from a previous line at the same index.
33
+
34
+ **Use it to seed your form**
35
+
36
+ ```tsx
37
+ const cart = await cartClient.get(cartId);
38
+ const giftLine = cart?.lines.edges.find(
39
+ (e) => e.node.productType === "GIFT_CARD",
40
+ );
41
+
42
+ <GiftRecipientForm
43
+ defaultValues={
44
+ giftLine?.node.giftCardRecipient ?? {
45
+ recipientEmail: "",
46
+ recipientName: "",
47
+ message: "",
48
+ }
49
+ }
50
+ onSubmit={(values) =>
51
+ cartClient.updateGiftCardRecipient(cartId, giftLine!.node.id, values)
52
+ }
53
+ />;
54
+ ```
55
+
56
+ **Migration**
57
+
58
+ Additive change — no rename, no signature change. The `CartLine` fragment
59
+ shipped by the SDK already includes the new field after this release, so
60
+ upgrading immediately unlocks the read path. No client code changes required
61
+ to keep existing flows working.
62
+
63
+ - ac2e306: Distinguish recoverable session loss from permanent cart denial.
64
+
65
+ Adds a new `CART_UNAUTHENTICATED` code to the cart error envelope. Until now,
66
+ both "your sign-in expired" and "this cart belongs to someone else" surfaced
67
+ as `CART_FORBIDDEN`, so a storefront had no way to know whether to send the
68
+ buyer to `/login` (recoverable) or to drop the cart cookie (permanent).
69
+ After the typical ~24h session TTL, the buyer would silently lose their
70
+ checkout state instead of being prompted to re-authenticate.
71
+
72
+ **New code** — `CART_UNAUTHENTICATED`
73
+
74
+ | Condition | Code | Recovery |
75
+ | ------------------------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------- |
76
+ | Anonymous request **or** expired/invalid session on a cart that belongs to a customer | `CART_UNAUTHENTICATED` | Re-authenticate. Cart is preserved (`cart-id` cookie kept). |
77
+ | Authenticated as a **different** customer than the cart owner | `CART_FORBIDDEN` (unchanged) | Drop the cart cookie and start over. |
78
+
79
+ The cart resource itself is intact in the `CART_UNAUTHENTICATED` case — the
80
+ buyer's saved address, line items, discount codes, and gift cards return as
81
+ soon as they sign in.
82
+
83
+ **New SDK signal** — `runner.onSessionExpired(...)`
84
+
85
+ The recovery runner now exposes a separate event for session loss, distinct
86
+ from `onExpired` (which is for cart-resource lifecycle: cart not found, cart
87
+ already completed). Subscribe once at the provider level and route the buyer
88
+ to `/login` with a `return_to` parameter; the cart cookie stays put.
89
+
90
+ ```ts
91
+ // app/providers.tsx
92
+ const unsubExpired = runner.onExpired(() => {
93
+ // cart resource gone — start fresh
94
+ router.push("/cart/new");
95
+ });
96
+
97
+ const unsubSession = runner.onSessionExpired(() => {
98
+ // session gone, cart preserved — sign back in
99
+ router.push(`/login?return_to=${encodeURIComponent(location.pathname)}`);
100
+ });
101
+ ```
102
+
103
+ The session branch throws `CartSessionRequiredError` (re-exported alongside
104
+ `CartRecoveryNotPossibleError`) and does **not** invoke `recreateAndRun` —
105
+ recreating the cart on a stale session would discard the buyer's progress.
106
+
107
+ **Imperative handling**
108
+
109
+ If you handle mutation errors directly (Server Action, custom hook), add one
110
+ case to your existing switch:
111
+
112
+ ```ts
113
+ switch (err.userErrors[0]?.code) {
114
+ case "CART_UNAUTHENTICATED":
115
+ redirect(`/login?return_to=${encodeURIComponent("/checkout")}`);
116
+ break;
117
+ case "CART_FORBIDDEN":
118
+ cookies().delete("cart-id");
119
+ redirect("/cart/expired");
120
+ break;
121
+ // ...existing cases for CART_NOT_FOUND / ALREADY_COMPLETED
122
+ }
123
+ ```
124
+
125
+ **Migration**
126
+
127
+ Additive change — no rename, no signature change. Existing `case 'CART_FORBIDDEN'`
128
+ branches keep working as before. Opt in to the new code at your own pace; until
129
+ you add the case, anonymous and cross-customer denials fall into your existing
130
+ default branch (one will be recoverable, one will not — adding the case lets
131
+ you distinguish them).
132
+
133
+ ## 16.0.0
134
+
135
+ ### Major Changes
136
+
137
+ - ca67b92: **BREAKING** — the formatting utilities switch from a hardcoded 13-currency map to runtime-driven `Intl.NumberFormat` resolution. Every ISO 4217 currency known to the runtime is now supported, with the locale-correct symbol and separators.
138
+
139
+ **API changes**
140
+ - `formatDate(date, locale)` — `locale` is now a required argument. The previous hardcoded `'en-US'` default is gone.
141
+ - `formatDateTime(date, locale)` — `locale` required.
142
+ - `formatNumber(num, locale)` — `locale` required.
143
+ - `formatPrice(price, locale?)` — `locale` is now optional. When omitted, the runtime default (`Intl.NumberFormat().resolvedOptions().locale`) is used. The previous `CURRENCY_LOCALES`-keyed auto-selection is gone.
144
+ - `formatAmount(amount, currencyCode, locale?)` — third argument added (optional).
145
+ - `formatPriceRange(min, max, locale?)` — third argument added (optional).
146
+ - `getCurrencySymbol(code, locale?)` — second argument added (optional). The symbol is derived from `Intl.NumberFormat.formatToParts()`; output depends on the locale (`getCurrencySymbol('PLN', 'pl-PL') === 'zł'`, `getCurrencySymbol('PLN', 'en-US') === 'PLN'`).
147
+
148
+ **Removed exports**
149
+ - `CURRENCY_SYMBOLS` — the 13-entry map is gone. Use `getCurrencySymbol(code, locale)` instead, or read the symbol from `Intl.NumberFormat.formatToParts()` directly.
150
+ - `CURRENCY_LOCALES` — the locale-to-currency map is gone. Pick the locale from the storefront's i18n context (or read `shop.localeToCurrencyMap` from the API for per-shop overrides) and pass it to `formatPrice` / `formatAmount`.
151
+
152
+ **Why**
153
+ - The hardcoded 13-currency map covered ~7% of the ISO 4217 currencies the API now ships in `CurrencyCode` (~180 entries). Every currency outside that list silently fell back to `en-US` formatting — a Brazilian (BRL) or Indian (INR) storefront had to either write its own formatter or accept US-style output.
154
+ - Locale↔currency is a **per-shop** decision (`shop.localeToCurrencyMap` in the schema is explicitly marked SSOT). A globally-hardcoded map in the SDK is the wrong place for that knowledge — it makes Polish customers of a multi-locale shop unable to see PLN in `'en-PL'` even when the merchant configured it.
155
+ - Money precision: `formatPrice` and `formatAmount` no longer apply `parseFloat` to `Money.amount`. Decimal strings are forwarded to `Intl.NumberFormat.format()` verbatim, preserving precision across locales and unusual subunit currencies (JPY/KRW zero-decimal, BHD/JOD/KWD three-decimal, ISK rounding).
156
+
157
+ **Migration**
158
+
159
+ ```ts
160
+ // Before
161
+ import {
162
+ formatDate,
163
+ formatPrice,
164
+ CURRENCY_LOCALES,
165
+ CURRENCY_SYMBOLS,
166
+ } from "@doswiftly/storefront-sdk";
167
+
168
+ formatDate(order.createdAt); // hardcoded "Dec 9, 2025"
169
+ formatPrice({ amount: "12.50", currencyCode: "PLN" }); // auto-picked pl-PL → "12,50 zł"
170
+ CURRENCY_LOCALES.PLN; // "pl-PL"
171
+ CURRENCY_SYMBOLS.PLN; // "zł"
172
+
173
+ // After (Client Components)
174
+ import { useLocale } from "next-intl";
175
+ import {
176
+ formatDate,
177
+ formatPrice,
178
+ getCurrencySymbol,
179
+ } from "@doswiftly/storefront-sdk";
180
+
181
+ const locale = useLocale(); // e.g. "pl-PL"
182
+ formatDate(order.createdAt, locale);
183
+ formatPrice({ amount: "12.50", currencyCode: "PLN" }, locale);
184
+ getCurrencySymbol("PLN", locale); // "zł"
185
+
186
+ // After (Server Components / Route Handlers)
187
+ import { getLocale } from "next-intl/server";
188
+ const locale = await getLocale();
189
+ formatDate(order.createdAt, locale);
190
+ ```
191
+
192
+ For per-shop locale↔currency overrides, read `shop.localeToCurrencyMap` from the Storefront API and resolve the locale yourself before calling `formatPrice` / `formatAmount`.
193
+
194
+ **New: Context-driven format hooks**
195
+
196
+ `@doswiftly/storefront-sdk/react` now ships convenience hooks that pull the active language from `useLanguageStore` (inside `<StorefrontProvider>`) and return a memoised, locale-bound formatter. Use them when you don't want to pass `locale` to every call:
197
+
198
+ ```ts
199
+ import {
200
+ useFormatPrice,
201
+ useFormatAmount,
202
+ useFormatPriceRange,
203
+ useFormatDate,
204
+ useFormatDateTime,
205
+ useFormatNumber,
206
+ useGetCurrencySymbol,
207
+ } from '@doswiftly/storefront-sdk/react';
208
+
209
+ function Cart() {
210
+ const formatPrice = useFormatPrice();
211
+ return <span>{formatPrice(item.price)}</span>;
212
+ }
213
+
214
+ function OrderRow() {
215
+ const formatDate = useFormatDate();
216
+ return <span>{formatDate(order.processedAt)}</span>;
217
+ }
218
+ ```
219
+
220
+ Each hook accepts an optional last `localeOverride` argument that wins over the store value — useful for a "show in US format" toggle on a single element:
221
+
222
+ ```ts
223
+ const formatPrice = useFormatPrice();
224
+ return <span>{formatPrice(item.price, 'en-US')}</span>;
225
+ ```
226
+
227
+ Resolution per call: `localeOverride` → `useLanguageStore().language` → runtime default.
228
+
229
+ Use the vanilla `formatPrice` / `formatDate` / etc. from `@doswiftly/storefront-sdk` (no hook) with an explicit `locale` for server components, e-mail templates, or any code path outside a provider.
230
+
231
+ `formatPercentage` is unchanged.
232
+
233
+ `@doswiftly/storefront-operations` bumped to keep linked parity — no operations changes.
234
+
235
+ ### Minor Changes
236
+
237
+ - 885c011: Re-export checkout types from the public API. `CartClient` methods added in 15.1.0 (`getAvailablePaymentMethods`, `getAvailableShippingMethods`) returned typed payloads, but the named types were not part of the public surface — component props and function signatures touching these payloads required `Awaited<ReturnType<...>>` workarounds. This change closes that gap.
238
+
239
+ **Newly exported types:**
240
+ - `PaymentMethod`, `AvailablePaymentMethods` — shape returned by `CartClient.getAvailablePaymentMethods()`
241
+ - `AvailableShippingMethod`, `AvailableShippingMethodsPayload`, `FreeShippingProgress`, `DeliveryEstimate`, `ShippingCarrier`, `DeliveryType` — shape returned by `CartClient.getAvailableShippingMethods()`
242
+ - `PickupPoint`, `PickupPointInput` — locker / pickup-point delivery (surfaced via `MailingAddress.pickupPoint` and `CartAddressInput.pickupPoint`)
243
+ - `CartAttributeInput` — input shape for `CartClient.updateAttributes()`
244
+ - `ShippingAddressInput` — input shape for the `availableShippingMethods` standalone query
245
+
246
+ **Example:**
247
+
248
+ ```ts
249
+ import type {
250
+ AvailableShippingMethodsPayload,
251
+ AvailableShippingMethod,
252
+ DeliveryType,
253
+ } from "@doswiftly/storefront-sdk";
254
+
255
+ function isPickup(method: AvailableShippingMethod): boolean {
256
+ return method.deliveryType === "PICKUP_POINT";
257
+ }
258
+ ```
259
+
260
+ No runtime changes — types already existed internally; this change only adds them to the published surface.
261
+
262
+ `@doswiftly/storefront-operations` bumped to keep linked parity — no operations changes.
263
+
264
+ - fd3d199: Expose `cart.cost.totalDiscount` and `cart.cost.totalShipping` through the `CartCost` fragment.
265
+
266
+ The schema already exposed `CartCost.totalDiscount: Money!` (aggregate of every entry in `cart.discountAllocations`) and `CartCost.totalShipping: Money` (cost of the currently selected shipping method, `null` until one is selected), but the shared `CartCost` fragment did not select them — so they were not part of the typed `Cart.cost` returned by `CartClient`. Storefronts wanting to render a single "Discounts: -X" row or a shipping summary had to either sum `discountAllocations` client-side (precision-sensitive across currencies) or query the schema directly and bypass the typed surface.
267
+
268
+ After this release, both fields are part of `Cart.cost` returned by every `CartClient` operation — `get`, `create`, `addItems`, `updateItems`, `removeItems`, `setShippingAddress`, `setBillingAddress`, `selectShippingMethod`, `selectPaymentMethod`, `updateBuyerIdentity`, `updateDiscountCodes`, `updateNote`, `updateAttributes`, `applyGiftCard`, `removeGiftCard`, `updateGiftCardRecipient`.
269
+
270
+ **Example:**
271
+
272
+ ```ts
273
+ const cart = await cartClient.get(cartId);
274
+
275
+ const summary = {
276
+ subtotal: cart.cost.subtotal,
277
+ discount: cart.cost.totalDiscount, // Money — defaults to amount 0
278
+ shipping: cart.cost.totalShipping, // Money | null — null until selectShippingMethod()
279
+ tax: cart.cost.totalTax,
280
+ total: cart.cost.total, // grand total — use directly on checkout summary
281
+ };
282
+ ```
283
+
284
+ `totalDiscount` is required (defaults to amount 0 when no discount applies). `totalShipping` is nullable — `null` means no shipping method has been selected yet, an amount of 0 means a free-shipping method was selected.
285
+
286
+ Additive — existing consumers keep every field they had before, no breaking change.
287
+
288
+ `@doswiftly/storefront-operations` bumped to keep linked parity (fragment update).
289
+
290
+ - 51091df: Expose `Cart.status` and `Cart.completedOrder` so storefronts can detect a completed (or expired / abandoned) cart on read, without having to attempt a mutation first and react to its `userErrors[].code`.
291
+
292
+ **New fields**
293
+ - `Cart.status: CartStatus!` — lifecycle status (`ACTIVE`, `ABANDONED`, `CONVERTED`, `RECOVERED`, `EXPIRED`). Only `ACTIVE` carts accept mutations; any other status rejects subsequent mutations with `CartErrorCode.ALREADY_COMPLETED`.
294
+ - `Cart.completedOrder: Order` — the order this cart converted into. Populated only when `status === CONVERTED`; null on every other status.
295
+
296
+ **Why**
297
+
298
+ When a buyer returned to the checkout page after completing their order (SSR re-render, deep link, "back" button after redirect), the SDK could only ask `cart(id)` for the cart and got the full pre-completion state back — every form value still there, the "Pay" button still active. The first mutation then failed with `ALREADY_COMPLETED`, after the buyer had already filled out fields. With `status` exposed, the storefront detects the terminal state on initial render and redirects to the order confirmation directly — using the `accessToken` on `completedOrder` for guest tracking, no extra `orderByToken` round-trip.
299
+
300
+ **Example**
301
+
302
+ ```ts
303
+ const cart = await cartClient.get(cartId);
304
+ if (!cart) {
305
+ // Cart not found — guide the buyer to create a new one
306
+ return redirect("/cart");
307
+ }
308
+ if (cart.status !== "ACTIVE") {
309
+ if (cart.completedOrder) {
310
+ // Render the order confirmation page off the data you already have
311
+ return redirect(`/order/${cart.completedOrder.accessToken}`);
312
+ }
313
+ // Abandoned / expired — start a fresh cart
314
+ return redirect("/cart/new");
315
+ }
316
+ // Render the checkout form normally
317
+ ```
318
+
319
+ Additive — every existing query that selects the shared `Cart` fragment now sees the two new fields automatically. No breaking change.
320
+
321
+ `@doswiftly/storefront-operations` bumped to keep linked parity — schema sync delivers the new fields and the `CartStatus` enum.
322
+
323
+ ### Patch Changes
324
+
325
+ - 7ce8ac4: Clarify how `apiUrl` and `shopSlug` are configured.
326
+
327
+ `createStorefrontClient` and `<StorefrontProvider>` take `apiUrl` and `shopSlug` as **explicit `config`** — the SDK does not read environment variables, sniff hostnames, or inspect request headers. The storefront supplies the values; the SDK uses them verbatim.
328
+
329
+ Scaffolded storefronts ship a config helper (`lib/graphql/config.ts`) that resolves the two values from these sources, in order:
330
+ 1. `doswiftly.config.ts` (preferred — committed file generated at `doswiftly init`)
331
+ 2. `NEXT_PUBLIC_API_URL` + `NEXT_PUBLIC_SHOP_SLUG` (fallback, used for local development)
332
+ 3. `http://localhost:8000` + `demo-shop` (defaults — smoke test only)
333
+
334
+ Scratch-built storefronts can skip the helper and pass values into `config={}` directly.
335
+
336
+ **What's new**
337
+ - `@doswiftly/storefront-sdk` README: new `## Configuration` section between `## Installation` and `## Quick Start`. Documents the explicit-config requirement, the three-source resolver shipped with scaffolded storefronts, and when env-var wiring actually matters.
338
+ - `@doswiftly/storefront-operations` `AGENTS.md`: new `### Configuration sources` convention inside `## Critical conventions — DO NOT hallucinate`. AI assistants now prefer `doswiftly.config.ts` and only fall back to the canonical env-var names.
339
+
340
+ Documentation only — no code change.
341
+
342
+ - f4efab9: Add `ShopConfigFields` fragment + `query ShopConfig` for `<StorefrontProvider>` setup.
343
+
344
+ `<StorefrontProvider shopData={...}>` from `@doswiftly/storefront-sdk/react` expects a `ShopConfig` payload with a specific shape: currency setup (including `localeToCurrencyMap`), language setup, and bot protection. Until now the storefront had to hand-write the field selection, and it was easy to miss `localeToCurrencyMap` (used internally by the SDK for browser-locale-based currency detection) or to add an extra field that didn't match the `ShopConfig` interface.
345
+
346
+ **New**
347
+ - `fragment ShopConfigFields on Shop` — minimal selection that matches the `ShopConfig` interface 1:1.
348
+ - `query ShopConfig { shop { ...ShopConfigFields } }` — ready-to-use query for the provider.
349
+
350
+ **Example**
351
+
352
+ ```ts
353
+ const SHOP_CONFIG_QUERY = /* GraphQL */ `
354
+ query ShopConfig {
355
+ shop {
356
+ ...ShopConfigFields
357
+ }
358
+ }
359
+ `;
360
+
361
+ // In a Server Component:
362
+ const { data } = await execute(SHOP_CONFIG_QUERY);
363
+ return <StorefrontProvider shopData={data.shop}>{children}</StorefrontProvider>;
364
+ ```
365
+
366
+ Use the larger `Shop` fragment + `query Shop` when you also need branding / contact / business hours for your UI; use `ShopConfig` when you only need to bootstrap the provider.
367
+
368
+ Additive — no breaking change.
369
+
370
+ `@doswiftly/storefront-sdk` bumped to keep linked parity — no code change.
371
+
3
372
  ## 15.1.0
4
373
 
5
374
  ### Minor Changes
package/README.md CHANGED
@@ -24,6 +24,34 @@ Layered runtime SDK for DoSwiftly Commerce storefronts. Framework-agnostic core
24
24
  pnpm add @doswiftly/storefront-sdk
25
25
  ```
26
26
 
27
+ ## Configuration
28
+
29
+ `createStorefrontClient` and `<StorefrontProvider>` take `apiUrl` and `shopSlug` as **explicit `config`** — the SDK does not read environment variables, sniff hostnames, or inspect request headers. The storefront supplies the values; the SDK uses them verbatim.
30
+
31
+ Scaffolded storefronts (`doswiftly init`) ship a `graphqlConfig` helper (`lib/graphql/config.ts`) that resolves both values from three sources in order:
32
+
33
+ | Source | When | What it gives you |
34
+ | --- | --- | --- |
35
+ | **`doswiftly.config.ts`** (preferred) | `doswiftly init` storefronts | A committed config file with both values. No env wiring needed in normal use. |
36
+ | `NEXT_PUBLIC_API_URL` + `NEXT_PUBLIC_SHOP_SLUG` (fallback) | Local development; storefronts scaffolded outside `doswiftly init` | Standard Next.js public env vars. `doswiftly dev` rewrites `NEXT_PUBLIC_API_URL` to a local CORS proxy at runtime so client-side calls don't hit production CORS headers. |
37
+ | `http://localhost:8000` + `demo-shop` (defaults) | Empty smoke test | Last-resort placeholders so the project boots before any config is written. |
38
+
39
+ If you go the env-var route, use **exactly these names** — `doswiftly dev` keys off them when overriding. Inventing `API_URL`, `STOREFRONT_URL`, or `TENANT_SLUG` means the dev proxy starts, but your storefront still calls the production API directly and you only learn about it on the first client-side mutation (build and SSR pass silently).
40
+
41
+ ```ts
42
+ // app/layout.tsx — Next.js App Router, template-generated wiring
43
+ import { graphqlConfig } from '@/lib/graphql/config';
44
+
45
+ <StorefrontProvider
46
+ config={{ apiUrl: graphqlConfig.apiUrl, shopSlug: graphqlConfig.shopSlug }}
47
+ shopData={shopData}
48
+ >
49
+ {children}
50
+ </StorefrontProvider>
51
+ ```
52
+
53
+ Scratch-built storefronts can read `process.env.NEXT_PUBLIC_*` directly and pass the values into `config={}` — the resolution helper is a convenience, not a requirement.
54
+
27
55
  ## Quick Start
28
56
 
29
57
  ### Core (framework-agnostic)
@@ -55,6 +55,54 @@ export type CartRecoverableErrorCode = (typeof CART_RECOVERABLE_ERROR_CODES)[num
55
55
  * `StorefrontError` carries at least one userError with a recoverable code.
56
56
  */
57
57
  export declare function isCartRecoverableError(err: unknown): err is StorefrontError;
58
+ /**
59
+ * Cart error codes that signal a missing or invalid auth context on a
60
+ * customer-owned cart — distinct from cart-resource recovery
61
+ * (`CART_RECOVERABLE_ERROR_CODES`).
62
+ *
63
+ * - `CART_UNAUTHENTICATED` — anonymous request OR present-but-invalid token
64
+ * (expired JWT, malformed). The cart resource is intact and reachable
65
+ * again after re-auth.
66
+ *
67
+ * Storefronts SHOULD preserve the `cart-id` cookie when handling these
68
+ * codes and prompt sign-in — the same cart resumes after a successful login.
69
+ */
70
+ export declare const CART_SESSION_ERROR_CODES: readonly ["CART_UNAUTHENTICATED"];
71
+ export type CartSessionErrorCode = (typeof CART_SESSION_ERROR_CODES)[number];
72
+ /**
73
+ * Type-safe predicate for "this error means the session is gone, but the
74
+ * cart resource is intact". Distinct from `isCartRecoverableError`
75
+ * (cart-resource recovery) — `isCartSessionError` matches auth-recoverable
76
+ * codes that should trigger re-auth flow, NOT cart cookie cleanup.
77
+ *
78
+ * Inspects `err.userErrors[].code` (structured field) — never matches against
79
+ * `err.message` (locale-dependent, non-stable).
80
+ */
81
+ export declare function isCartSessionError(err: unknown): err is StorefrontError;
82
+ /**
83
+ * Event fired when the runner observes a session error (`CART_UNAUTHENTICATED`).
84
+ * Cart-id cookie is NOT cleared — the cart resource is intact and the buyer
85
+ * should be prompted to re-authenticate; after a successful login the same
86
+ * cart resumes.
87
+ */
88
+ export interface CartSessionExpiredEvent {
89
+ /** Cart id from the cookie at the time of the failure (null when no cart was set). */
90
+ oldCartId: string | null;
91
+ /** Operation name from `CartRecoveryOperation.name` if provided, otherwise 'unknown'. */
92
+ operation: string;
93
+ /** Original `StorefrontError` carrying the `CART_UNAUTHENTICATED` userError. */
94
+ cause: unknown;
95
+ }
96
+ /**
97
+ * Thrown when a cart operation fails with a session error
98
+ * (`CART_UNAUTHENTICATED`). Distinct from `CartRecoveryNotPossibleError`
99
+ * (cart-resource issue) — storefronts should redirect to a sign-in flow
100
+ * and retry the operation after re-auth.
101
+ */
102
+ export declare class CartSessionRequiredError extends Error {
103
+ readonly cause: unknown;
104
+ constructor(cause: unknown, message?: string);
105
+ }
58
106
  /**
59
107
  * Cookie I/O port. Caller's responsibility — core never touches `document.cookie`.
60
108
  *
@@ -137,6 +185,14 @@ export interface ExecuteWithCartRecoveryOptions<T> {
137
185
  cookieMaxAge?: number;
138
186
  /** Optional listener fired before the runner throws `CartRecoveryNotPossibleError`. */
139
187
  onExpired?: (event: CartExpiredEvent) => void;
188
+ /**
189
+ * Optional listener fired before the runner throws
190
+ * `CartSessionRequiredError`. Distinct from `onExpired` — the cart
191
+ * resource is intact, but the session/auth context is missing or invalid.
192
+ * Storefront should redirect to sign-in and retry the operation after
193
+ * re-auth; the same cart resumes.
194
+ */
195
+ onSessionExpired?: (event: CartSessionExpiredEvent) => void;
140
196
  }
141
197
  /**
142
198
  * Runs an operation against the current cart, recovering once on
@@ -158,6 +214,13 @@ export interface CartRecoveryRunner {
158
214
  * fails. Returns an unsubscribe function. Multiple subscribers are supported.
159
215
  */
160
216
  onExpired(listener: (event: CartExpiredEvent) => void): () => void;
217
+ /**
218
+ * Subscribe to `session-expired` events fired when the runner observes a
219
+ * `CART_UNAUTHENTICATED` userError (cart resource intact, re-auth required).
220
+ * The cart-id cookie is preserved so the same cart resumes after a successful
221
+ * sign-in. Returns an unsubscribe function. Multiple subscribers are supported.
222
+ */
223
+ onSessionExpired(listener: (event: CartSessionExpiredEvent) => void): () => void;
161
224
  /** Underlying cart client (for advanced flows / read-only helpers). */
162
225
  readonly cartClient: CartClient;
163
226
  /** Underlying cookie store (for advanced flows). */
@@ -1 +1 @@
1
- {"version":3,"file":"cart-recovery.d.ts","sourceRoot":"","sources":["../../../src/core/cart/cart-recovery.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cart-recovery.d.ts","sourceRoot":"","sources":["../../../src/core/cart/cart-recovery.ts"],"names":[],"mappings":"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"}
@@ -65,6 +65,53 @@ export function isCartRecoverableError(err) {
65
65
  return false;
66
66
  return err.userErrors.some((ue) => typeof ue.code === 'string' && RECOVERABLE_CODE_SET.has(ue.code));
67
67
  }
68
+ // ---------------------------------------------------------------------------
69
+ // Session error codes — auth-recoverable, distinct from cart-resource recovery
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * Cart error codes that signal a missing or invalid auth context on a
73
+ * customer-owned cart — distinct from cart-resource recovery
74
+ * (`CART_RECOVERABLE_ERROR_CODES`).
75
+ *
76
+ * - `CART_UNAUTHENTICATED` — anonymous request OR present-but-invalid token
77
+ * (expired JWT, malformed). The cart resource is intact and reachable
78
+ * again after re-auth.
79
+ *
80
+ * Storefronts SHOULD preserve the `cart-id` cookie when handling these
81
+ * codes and prompt sign-in — the same cart resumes after a successful login.
82
+ */
83
+ export const CART_SESSION_ERROR_CODES = ['CART_UNAUTHENTICATED'];
84
+ const SESSION_CODE_SET = new Set(CART_SESSION_ERROR_CODES);
85
+ /**
86
+ * Type-safe predicate for "this error means the session is gone, but the
87
+ * cart resource is intact". Distinct from `isCartRecoverableError`
88
+ * (cart-resource recovery) — `isCartSessionError` matches auth-recoverable
89
+ * codes that should trigger re-auth flow, NOT cart cookie cleanup.
90
+ *
91
+ * Inspects `err.userErrors[].code` (structured field) — never matches against
92
+ * `err.message` (locale-dependent, non-stable).
93
+ */
94
+ export function isCartSessionError(err) {
95
+ if (!(err instanceof StorefrontError))
96
+ return false;
97
+ if (err.userErrors.length === 0)
98
+ return false;
99
+ return err.userErrors.some((ue) => typeof ue.code === 'string' && SESSION_CODE_SET.has(ue.code));
100
+ }
101
+ /**
102
+ * Thrown when a cart operation fails with a session error
103
+ * (`CART_UNAUTHENTICATED`). Distinct from `CartRecoveryNotPossibleError`
104
+ * (cart-resource issue) — storefronts should redirect to a sign-in flow
105
+ * and retry the operation after re-auth.
106
+ */
107
+ export class CartSessionRequiredError extends Error {
108
+ cause;
109
+ constructor(cause, message) {
110
+ super(message ?? 'Session required — please sign in to continue', { cause });
111
+ this.name = 'CartSessionRequiredError';
112
+ this.cause = cause;
113
+ }
114
+ }
68
115
  /**
69
116
  * Thrown when an operation hits a recoverable cart error but recovery is not
70
117
  * possible (no `recreateAndRun`, or recovery itself failed). UI should clear
@@ -109,7 +156,7 @@ async function acquireCartId(coord, factory) {
109
156
  * consumers can wire this directly without the runner factory.
110
157
  */
111
158
  export async function executeWithCartRecovery(opts) {
112
- const { cartClient, cookieStore, operation, ensureCart, cookieMaxAge, onExpired } = opts;
159
+ const { cartClient, cookieStore, operation, ensureCart, cookieMaxAge, onExpired, onSessionExpired } = opts;
113
160
  const coord = opts.recoveryCoordinator ?? createCoordinator();
114
161
  const opName = operation.name ?? 'unknown';
115
162
  // Phase 0 — ensure cart exists (cookie may be empty on first interaction).
@@ -126,6 +173,17 @@ export async function executeWithCartRecovery(opts) {
126
173
  return await operation.run(cartId);
127
174
  }
128
175
  catch (err) {
176
+ // Session loss — cart resource intact, re-auth needed. Preserve cookie
177
+ // so the same cart resumes after a successful login.
178
+ if (isCartSessionError(err)) {
179
+ const sessionEvent = {
180
+ oldCartId: cartId,
181
+ operation: opName,
182
+ cause: err,
183
+ };
184
+ onSessionExpired?.(sessionEvent);
185
+ throw new CartSessionRequiredError(err);
186
+ }
129
187
  if (!isCartRecoverableError(err))
130
188
  throw err;
131
189
  const oldCartId = cartId;
@@ -181,9 +239,20 @@ export async function executeWithCartRecovery(opts) {
181
239
  export function createCartRecoveryRunner(options) {
182
240
  const { cartClient, cookieStore, ensureCart, cookieMaxAge } = options;
183
241
  const coordinator = createCoordinator();
184
- const listeners = new Set();
185
- function emit(event) {
186
- for (const listener of listeners) {
242
+ const expiredListeners = new Set();
243
+ const sessionListeners = new Set();
244
+ function emitExpired(event) {
245
+ for (const listener of expiredListeners) {
246
+ try {
247
+ listener(event);
248
+ }
249
+ catch {
250
+ // Listeners must not break recovery flow — swallow listener exceptions.
251
+ }
252
+ }
253
+ }
254
+ function emitSessionExpired(event) {
255
+ for (const listener of sessionListeners) {
187
256
  try {
188
257
  listener(event);
189
258
  }
@@ -203,7 +272,8 @@ export function createCartRecoveryRunner(options) {
203
272
  ensureCart,
204
273
  cookieMaxAge,
205
274
  recoveryCoordinator: coordinator,
206
- onExpired: emit,
275
+ onExpired: emitExpired,
276
+ onSessionExpired: emitSessionExpired,
207
277
  };
208
278
  return executeWithCartRecovery(internalOpts);
209
279
  },
@@ -215,9 +285,18 @@ export function createCartRecoveryRunner(options) {
215
285
  return await cartClient.get(cartId);
216
286
  }
217
287
  catch (err) {
288
+ if (isCartSessionError(err)) {
289
+ // Cart resource intact — re-auth required. Preserve cookie.
290
+ emitSessionExpired({
291
+ oldCartId: cartId,
292
+ operation: 'getCart',
293
+ cause: err,
294
+ });
295
+ throw new CartSessionRequiredError(err);
296
+ }
218
297
  if (isCartRecoverableError(err)) {
219
298
  cookieStore.clear();
220
- emit({
299
+ emitExpired({
221
300
  reason: 'state-dependent',
222
301
  oldCartId: cartId,
223
302
  operation: 'getCart',
@@ -229,8 +308,12 @@ export function createCartRecoveryRunner(options) {
229
308
  }
230
309
  },
231
310
  onExpired(listener) {
232
- listeners.add(listener);
233
- return () => listeners.delete(listener);
311
+ expiredListeners.add(listener);
312
+ return () => expiredListeners.delete(listener);
313
+ },
314
+ onSessionExpired(listener) {
315
+ sessionListeners.add(listener);
316
+ return () => sessionListeners.delete(listener);
234
317
  },
235
318
  };
236
319
  }
@@ -93,7 +93,7 @@ export type PaymentSession = PaymentSessionFragment;
93
93
  export type DiscountValidationResult = CartValidateDiscountCodeQuery['cartValidateDiscountCode'];
94
94
  export type DiscountInfo = NonNullable<DiscountValidationResult['discount']>;
95
95
  export type DiscountValidationError = NonNullable<DiscountValidationResult['error']>;
96
- export type { CartCreateInput, CartLineInput, CartLineUpdateInput, CartBuyerIdentityInput, CartAddressInput, CartAttributeInput, CartCompleteInput, CartApplyGiftCardInput, CartRemoveGiftCardInput, CartSelectPaymentMethodInput, CartSelectShippingMethodInput, CartSetBillingAddressInput, CartSetShippingAddressInput, CartUpdateGiftCardRecipientInput, PaymentCreateInput, ShippingAddressInput, DeliveryType, AttributeSelectionInput as CartAttributeSelectionInput, PaymentMethodType, PaymentInitiationFlow, DiscountApplicationType, DiscountErrorCode, CurrencyCode, CountryCode, LanguageCode, ProductTypeEnum, WeightUnit, CartWarningCode, AttributeType, AttributeFillingMode, AttributeBillingMode, AttributeOptionSurchargeType, StorefrontOrderStatus, OrderPaymentStatus, OrderFulfillmentStatus, } from '../generated/operation-types';
96
+ export type { CartCreateInput, CartLineInput, CartLineUpdateInput, CartBuyerIdentityInput, CartAddressInput, CartAttributeInput, CartCompleteInput, CartApplyGiftCardInput, CartRemoveGiftCardInput, CartSelectPaymentMethodInput, CartSelectShippingMethodInput, CartSetBillingAddressInput, CartSetShippingAddressInput, CartUpdateGiftCardRecipientInput, PaymentCreateInput, ShippingAddressInput, PickupPointInput, DeliveryType, DeliveryEstimate, ShippingCarrier, FreeShippingProgress, PickupPoint, AttributeSelectionInput as CartAttributeSelectionInput, PaymentMethodType, PaymentInitiationFlow, DiscountApplicationType, DiscountErrorCode, CurrencyCode, CountryCode, LanguageCode, ProductTypeEnum, WeightUnit, CartWarningCode, AttributeType, AttributeFillingMode, AttributeBillingMode, AttributeOptionSurchargeType, StorefrontOrderStatus, OrderPaymentStatus, OrderFulfillmentStatus, } from '../generated/operation-types';
97
97
  /**
98
98
  * Machine-readable error code surfaced when `createPayment` fails. The call
99
99
  * throws a `StorefrontError` on `userErrors` — read `.userErrors[0].code` off