@doswiftly/storefront-sdk 11.0.0 → 11.2.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,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 11.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 760f965: Added automatic stale-cart recovery to the core SDK so every consumer (React, Vue, mobile, CLI, SSR) gets the same protection against `CART_NOT_FOUND` / `ALREADY_COMPLETED` errors without re-implementing detection, cookie cleanup, and retry orchestration.
8
+
9
+ **What's new** (importable from `@doswiftly/storefront-sdk`):
10
+ - `isCartRecoverableError(err)` — type-safe predicate that inspects `err.userErrors[].code` (works across PL/EN/any backend locale, unlike message string matching).
11
+ - `CART_RECOVERABLE_ERROR_CODES` — readonly tuple of codes that warrant cart recreation; mirrored against the backend `CartErrorCode` enum by a drift test.
12
+ - `executeWithCartRecovery(opts)` — pure async orchestrator for any consumer.
13
+ - `createCartRecoveryRunner({ cartClient, cookieStore })` — DX-friendly factory that curries the dependencies, shares a Phase-0 concurrency mutex across operations, and exposes an `onExpired(listener)` subscription for global UI feedback.
14
+ - `CartCookieStore` — port interface (`get` / `set` / `clear`) the caller implements once per runtime. From `@doswiftly/storefront-sdk/react` we now also export `createBrowserCartCookieStore()` (SSR-safe).
15
+ - `CartRecoveryNotPossibleError` — thrown when an operation cannot be safely replayed (line-id / shipping-method references the dead cart). Carries `reason: 'state-dependent' | 'recreate-failed' | 'retry-also-failed'` for diagnostic UX.
16
+ - `recreateWithInput(input)` / `recreateWithLines(lines)` — sugar helpers for the most common atomic `cartCreate(payload)` recovery strategies.
17
+
18
+ **Per-operation taxonomy (decision is in the SDK, not in caller code):**
19
+
20
+ | Operation | Strategy |
21
+ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
22
+ | `addItems`, `updateBuyerIdentity`, `setShippingAddress`, `updateDiscountCodes`, `updateNote` | Auto-replay via a single atomic `cartCreate(input)` — recovery is invisible to the caller |
23
+ | `updateItems`, `removeItems` | Bail with `CartRecoveryNotPossibleError` and emit a `cart-expired` event — replaying on a fresh empty cart would silently lose the user's intent |
24
+
25
+ **React updates:**
26
+ - `useCartManager` now uses the recovery runner internally. Detection is code-based (no more PL/EN regression — previous implementation matched `'cart not found'` in the error message, which never matched a Polish backend response). Recovery now covers all mutations exposed by the hook, not just `addItem`. New methods: `updateBuyerIdentity`, `setShippingAddress`. New API: `onExpired(listener)` returning an unsubscribe function.
27
+ - `createCartStore` now recovers `addToCart` automatically. New optional `CartActions.createCartWithLines(lines)` lets templates wire the atomic single-round-trip path; without it the store falls back to `createCart()` + `addLines()`. `updateQuantity` and `removeFromCart` bail with the `cart-expired` event when the cart is gone (previously the store kept its local id pointing at a dead cart). `CartStoreConfig.onExpired?(event)` lets the consumer subscribe.
28
+
29
+ **Migration:**
30
+ - No required code changes for existing React consumers — `useCartManager` and `createCartStore` keep their public surface.
31
+ - If you previously caught `StorefrontError` to detect stale carts via message string matching, switch to `isCartRecoverableError(err)` (code-based, locale-proof) or — preferably — subscribe to `runner.onExpired(...)` / `useCartManager().onExpired(...)` / `createCartStore({ onExpired })`.
32
+ - Non-React consumers should wrap their cart mutations in `createCartRecoveryRunner` and implement a `CartCookieStore` adapter for their environment (browser via `createBrowserCartCookieStore()`, server-side via `next/headers`, CLI via in-memory `Map`, mobile via AsyncStorage).
33
+
34
+ `@doswiftly/storefront-operations` is bumped in lockstep (no code change — required because the packages are linked and ship together).
35
+
36
+ ## 11.1.0
37
+
38
+ ### Minor Changes
39
+
40
+ - c2e80b2: Add `paymentCreate` — initiate a payment session for a completed order.
41
+
42
+ After `cartComplete` returns an order, call `paymentCreate` (when `order.canCreatePayment` is `true`) to start the payment. Orders with an offline payment method (cash on delivery, manual bank transfer) skip this step. The returned `PaymentSession` is flow-aware — branch on `session.flow`:
43
+ - `ONLINE_REDIRECT` → redirect the browser to `session.redirectUrl`
44
+ - `ONLINE_EMBEDDED` → render an in-page widget with `session.clientSecret`
45
+ - `INSTANT_DIRECT` → already settled, read `session.status`
46
+
47
+ **`@doswiftly/storefront-operations`** — new `PaymentCreate` mutation and `PaymentSession` fragment.
48
+
49
+ **`@doswiftly/storefront-sdk`** — new `CartClient.createPayment(input)` method, plus the `PaymentSession`, `PaymentCreateInput`, `PaymentInitiationFlow` and `PaymentErrorCode` types.
50
+
51
+ ```ts
52
+ const { order } = await cartClient.complete({ cartId });
53
+ if (order.canCreatePayment) {
54
+ const session = await cartClient.createPayment({
55
+ orderId: order.id,
56
+ returnUrl: "https://my-shop.example/checkout/complete",
57
+ });
58
+ if (session.flow === "ONLINE_REDIRECT") {
59
+ window.location.href = session.redirectUrl!;
60
+ }
61
+ }
62
+ ```
63
+
64
+ `paymentCreate` is idempotent on the order — re-calling returns the existing still-valid session instead of creating a duplicate, so it is safe to retry. `returnUrl` / `cancelUrl` are optional and, when supplied, must point to a verified domain of the shop. The call throws on validation/business errors — inspect `error.userErrors[0].code` (`PaymentErrorCode`): `ORDER_NOT_FOUND`, `ORDER_ALREADY_PAID`, `ORDER_NOT_PAYABLE`, `PAYMENT_PROVIDER_NOT_CONFIGURED`, `RETURN_URL_INVALID`, `INVALID_ID_FORMAT`, `PAYMENT_FAILED`.
65
+
3
66
  ## 11.0.0
4
67
 
5
68
  ### Major Changes
package/README.md CHANGED
@@ -158,6 +158,89 @@ const existing = await cartClient.get(cartId);
158
158
 
159
159
  Auto-throws `StorefrontError` with code `USER_ERROR` on validation failures.
160
160
 
161
+ ### Cart recovery (stale carts)
162
+
163
+ Carts can become unusable between reads and writes — they expire (TTL), lock after checkout (`ALREADY_COMPLETED`), or get cleaned up by a purge worker. Write mutations then reject with `userErrors[].code = 'CART_NOT_FOUND'` even though the previous `cart(id)` query succeeded.
164
+
165
+ The recovery utilities in core make this transparent for the caller. Every operation classifies itself: replay-safe operations (`addItems`, `updateBuyerIdentity`, `setShippingAddress`, discount codes, note) auto-recover via an atomic `cartCreate(input)`; state-dependent operations (`updateItems`, `removeItems`) bail with a `CartRecoveryNotPossibleError` and emit a `cart-expired` event so your UI can surface a single toast/banner instead of every call site handling the error.
166
+
167
+ ```typescript
168
+ import {
169
+ CartClient,
170
+ createCartRecoveryRunner,
171
+ recreateWithInput,
172
+ CartRecoveryNotPossibleError,
173
+ type CartCookieStore,
174
+ } from '@doswiftly/storefront-sdk';
175
+
176
+ // Implement the cookie port for your runtime (or use createBrowserCartCookieStore from /react)
177
+ const cookieStore: CartCookieStore = {
178
+ get: () => readCartCookie(),
179
+ set: (cartId) => writeCartCookie(cartId),
180
+ clear: () => deleteCartCookie(),
181
+ };
182
+
183
+ const cartClient = new CartClient(client);
184
+ const runner = createCartRecoveryRunner({ cartClient, cookieStore });
185
+
186
+ runner.onExpired((event) => {
187
+ // Fired when the runner cannot transparently recover. UI prompts user to retry.
188
+ console.warn(`Cart expired (${event.reason}). Reset local state.`);
189
+ });
190
+
191
+ // Auto-replay: storefront caller never thinks about recovery
192
+ const { cart, warnings } = await runner.execute({
193
+ name: 'addItems',
194
+ run: (cartId) => cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]),
195
+ recreateAndRun: recreateWithInput({ lines: [{ variantId: 'v-123', quantity: 1 }] }),
196
+ });
197
+
198
+ // Bail-on-stale: operation throws CartRecoveryNotPossibleError if cart is gone
199
+ try {
200
+ await runner.execute({
201
+ name: 'updateItem',
202
+ run: (cartId) => cartClient.updateItems(cartId, [{ id: 'line-1', quantity: 2 }]),
203
+ // No recreateAndRun — replaying on an empty cart would silently lose the user's intent.
204
+ });
205
+ } catch (err) {
206
+ if (err instanceof CartRecoveryNotPossibleError) {
207
+ // Already handled by the onExpired listener above.
208
+ } else {
209
+ throw err;
210
+ }
211
+ }
212
+ ```
213
+
214
+ Detection inspects `err.userErrors[].code` (`CART_NOT_FOUND` / `ALREADY_COMPLETED`) — locale-independent.
215
+
216
+ ### React: `useCartManager` (DX-first hook)
217
+
218
+ `useCartManager` is the React wrapper around the recovery runner — same per-operation taxonomy, just `useState`-driven loading/error and an `onExpired` subscription helper:
219
+
220
+ ```tsx
221
+ 'use client';
222
+ import { useEffect } from 'react';
223
+ import { useCartManager } from '@doswiftly/storefront-sdk/react';
224
+ import { toast } from 'sonner';
225
+
226
+ export function CartButton() {
227
+ const { addItem, updateItem, onExpired, isLoading } = useCartManager();
228
+
229
+ useEffect(
230
+ () => onExpired((e) => toast.error(e.reason === 'state-dependent' ? 'Twój koszyk wygasł, dodaj produkty ponownie' : 'Nie udało się odzyskać koszyka')),
231
+ [onExpired],
232
+ );
233
+
234
+ return (
235
+ <button onClick={() => addItem([{ variantId: 'v-123', quantity: 1 }])} disabled={isLoading}>
236
+ Add to cart
237
+ </button>
238
+ );
239
+ }
240
+ ```
241
+
242
+ Methods returning `CartMutationOutcome` are: `addItem`, `updateBuyerIdentity`, `setShippingAddress`, `updateDiscountCodes`, `updateNote` (auto-replay) and `updateItem`, `removeItem` (bail + `onExpired`). Use `clearCart` to reset locally without backend call.
243
+
161
244
  ### AuthClient
162
245
 
163
246
  ```typescript
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-cart-manager.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-cart-manager.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/use-cart-manager.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,400 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ /**
4
+ * Unit tests for useCartManager — DX-first cart hook with automatic stale-cart
5
+ * recovery.
6
+ *
7
+ * Mocks the StorefrontClientContext directly (no full StorefrontProvider stack)
8
+ * so each test owns its CartClient spy and can simulate recoverable / fatal
9
+ * errors per-mutation. Recovery semantics under the hood are covered by
10
+ * cart-recovery.test.ts — these tests focus on the React wrapper:
11
+ *
12
+ * - per-operation taxonomy reaches the right code path (replay vs bail)
13
+ * - onExpired subscribers fire on bail / recreate-fail
14
+ * - isLoading / error state transitions are correct
15
+ * - cookie-based cart id persistence works through document.cookie
16
+ *
17
+ * Note: hook is rendered via `renderHook` from @testing-library/react. Awaits
18
+ * inside `act(async () => { ... })` flush React effects before assertions.
19
+ */
20
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21
+ import { renderHook, act } from '@testing-library/react';
22
+ import { useCartManager } from '../../react/hooks/use-cart-manager';
23
+ import { StorefrontError, ErrorCodes } from '../../core/errors';
24
+ import { CartClient } from '../../core/cart/cart-client';
25
+ // ---------------------------------------------------------------------------
26
+ // Mock StorefrontClientContext — supply our own cartClient per test
27
+ // ---------------------------------------------------------------------------
28
+ let activeCartClient;
29
+ vi.mock('../../react/providers/storefront-client-provider', () => ({
30
+ useStorefrontClientContext: () => ({
31
+ client: {},
32
+ cartClient: activeCartClient,
33
+ authClient: {},
34
+ }),
35
+ }));
36
+ // ---------------------------------------------------------------------------
37
+ // Fixtures
38
+ // ---------------------------------------------------------------------------
39
+ function mockStorefrontClient() {
40
+ return {
41
+ query: vi.fn(async () => ({})),
42
+ mutate: vi.fn(async () => ({})),
43
+ use: vi.fn(),
44
+ };
45
+ }
46
+ const fakeCart = (id = 'cart-fresh') => ({
47
+ id,
48
+ lines: { edges: [], nodes: [], pageInfo: {}, totalCount: 0 },
49
+ totalQuantity: 0,
50
+ cost: { total: { amount: '0', currencyCode: 'PLN' }, subtotal: { amount: '0', currencyCode: 'PLN' } },
51
+ });
52
+ const FAKE_CART = fakeCart();
53
+ function recoverableError(code, message) {
54
+ return new StorefrontError({
55
+ code: ErrorCodes.USER_ERROR,
56
+ message: message ?? 'User errors in response',
57
+ userErrors: [{ message: message ?? 'simulated', code, field: [] }],
58
+ });
59
+ }
60
+ function nonRecoverableError() {
61
+ return new StorefrontError({
62
+ code: ErrorCodes.USER_ERROR,
63
+ message: 'Out of stock',
64
+ userErrors: [{ message: 'low stock', code: 'NOT_ENOUGH_IN_STOCK', field: [] }],
65
+ });
66
+ }
67
+ function setCartCookie(value) {
68
+ if (value === null) {
69
+ document.cookie = 'cart-id=;max-age=0;path=/';
70
+ }
71
+ else {
72
+ document.cookie = `cart-id=${value};path=/`;
73
+ }
74
+ }
75
+ function getCartCookie() {
76
+ const match = document.cookie.match(/(?:^|;\s*)cart-id=([^;]*)/);
77
+ return match ? decodeURIComponent(match[1]) : null;
78
+ }
79
+ const Wrapper = ({ children }) => _jsx(_Fragment, { children: children });
80
+ beforeEach(() => {
81
+ activeCartClient = new CartClient(mockStorefrontClient());
82
+ setCartCookie(null);
83
+ });
84
+ afterEach(() => {
85
+ vi.restoreAllMocks();
86
+ setCartCookie(null);
87
+ });
88
+ // ---------------------------------------------------------------------------
89
+ // addItem — auto-replay
90
+ // ---------------------------------------------------------------------------
91
+ describe('useCartManager — addItem (auto-replay)', () => {
92
+ it('creates cart on first add when cookie empty (Phase 0)', async () => {
93
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({ cart: FAKE_CART, warnings: [] });
94
+ const addSpy = vi.spyOn(activeCartClient, 'addItems').mockResolvedValue({ cart: FAKE_CART, warnings: [] });
95
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
96
+ await act(async () => {
97
+ await result.current.addItem([{ variantId: 'v1', quantity: 1 }]);
98
+ });
99
+ expect(createSpy).toHaveBeenCalledTimes(1);
100
+ expect(addSpy).toHaveBeenCalledWith('cart-fresh', [{ variantId: 'v1', quantity: 1 }]);
101
+ expect(getCartCookie()).toBe('cart-fresh');
102
+ });
103
+ it('uses cookie cart-id on subsequent adds', async () => {
104
+ setCartCookie('existing-cart');
105
+ const createSpy = vi.spyOn(activeCartClient, 'create');
106
+ const addSpy = vi.spyOn(activeCartClient, 'addItems').mockResolvedValue({ cart: FAKE_CART, warnings: [] });
107
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
108
+ await act(async () => {
109
+ await result.current.addItem([{ variantId: 'v1', quantity: 1 }]);
110
+ });
111
+ expect(createSpy).not.toHaveBeenCalled();
112
+ expect(addSpy).toHaveBeenCalledWith('existing-cart', [{ variantId: 'v1', quantity: 1 }]);
113
+ });
114
+ it('auto-recovers on CART_NOT_FOUND via atomic cartCreate({ lines })', async () => {
115
+ setCartCookie('stale-cart');
116
+ const stale = recoverableError('CART_NOT_FOUND');
117
+ vi.spyOn(activeCartClient, 'addItems').mockRejectedValue(stale);
118
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
119
+ cart: fakeCart('cart-recovered'),
120
+ warnings: [],
121
+ });
122
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
123
+ await act(async () => {
124
+ const outcome = await result.current.addItem([{ variantId: 'v1', quantity: 2 }]);
125
+ expect(outcome.cart.id).toBe('cart-recovered');
126
+ });
127
+ // Atomic recreate: cartCreate({ lines: [...] }) — NOT empty create + addItems
128
+ expect(createSpy).toHaveBeenCalledWith({ lines: [{ variantId: 'v1', quantity: 2 }] });
129
+ expect(getCartCookie()).toBe('cart-recovered');
130
+ });
131
+ it('auto-recovers on ALREADY_COMPLETED (post-checkout locked cart)', async () => {
132
+ setCartCookie('locked-cart');
133
+ vi.spyOn(activeCartClient, 'addItems').mockRejectedValue(recoverableError('ALREADY_COMPLETED'));
134
+ vi.spyOn(activeCartClient, 'create').mockResolvedValue({
135
+ cart: fakeCart('post-checkout-cart'),
136
+ warnings: [],
137
+ });
138
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
139
+ await act(async () => {
140
+ await result.current.addItem([{ variantId: 'v1' }]);
141
+ });
142
+ expect(getCartCookie()).toBe('post-checkout-cart');
143
+ });
144
+ it('PL locale message does not break detection (regression for legacy useCartManager bug)', async () => {
145
+ setCartCookie('stale');
146
+ vi.spyOn(activeCartClient, 'addItems').mockRejectedValue(recoverableError('CART_NOT_FOUND', 'Nie znaleziono koszyka'));
147
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
148
+ cart: fakeCart('pl-recovered'),
149
+ warnings: [],
150
+ });
151
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
152
+ await act(async () => {
153
+ await result.current.addItem([{ variantId: 'v1' }]);
154
+ });
155
+ expect(createSpy).toHaveBeenCalledTimes(1);
156
+ });
157
+ it('propagates non-recoverable errors (stock) without recovery', async () => {
158
+ setCartCookie('cart-1');
159
+ const stock = nonRecoverableError();
160
+ vi.spyOn(activeCartClient, 'addItems').mockRejectedValue(stock);
161
+ const createSpy = vi.spyOn(activeCartClient, 'create');
162
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
163
+ await act(async () => {
164
+ await expect(result.current.addItem([{ variantId: 'oos' }])).rejects.toBe(stock);
165
+ });
166
+ expect(createSpy).not.toHaveBeenCalled();
167
+ expect(getCartCookie()).toBe('cart-1');
168
+ expect(result.current.error).toBe('Out of stock');
169
+ });
170
+ });
171
+ // ---------------------------------------------------------------------------
172
+ // updateItem / removeItem — bail
173
+ // ---------------------------------------------------------------------------
174
+ describe('useCartManager — updateItem / removeItem (bail)', () => {
175
+ it('updateItem on CART_NOT_FOUND throws CartRecoveryNotPossibleError + fires onExpired', async () => {
176
+ setCartCookie('stale');
177
+ vi.spyOn(activeCartClient, 'updateItems').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
178
+ const createSpy = vi.spyOn(activeCartClient, 'create');
179
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
180
+ const listener = vi.fn();
181
+ act(() => {
182
+ result.current.onExpired(listener);
183
+ });
184
+ await act(async () => {
185
+ await expect(result.current.updateItem([{ id: 'line-1', quantity: 5 }])).rejects.toMatchObject({ name: 'CartRecoveryNotPossibleError', reason: 'state-dependent' });
186
+ });
187
+ expect(createSpy).not.toHaveBeenCalled(); // no replay — operation is state-dependent
188
+ expect(getCartCookie()).toBeNull(); // cookie cleared
189
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ reason: 'state-dependent', operation: 'updateItem' }));
190
+ });
191
+ it('removeItem on ALREADY_COMPLETED bails the same way', async () => {
192
+ setCartCookie('locked');
193
+ vi.spyOn(activeCartClient, 'removeItems').mockRejectedValue(recoverableError('ALREADY_COMPLETED'));
194
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
195
+ const listener = vi.fn();
196
+ act(() => {
197
+ result.current.onExpired(listener);
198
+ });
199
+ await act(async () => {
200
+ await expect(result.current.removeItem(['line-1'])).rejects.toMatchObject({
201
+ name: 'CartRecoveryNotPossibleError',
202
+ });
203
+ });
204
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ operation: 'removeItem' }));
205
+ });
206
+ it('updateItem propagates non-recoverable error without bail', async () => {
207
+ setCartCookie('cart-1');
208
+ const validation = new Error('Quantity must be positive');
209
+ vi.spyOn(activeCartClient, 'updateItems').mockRejectedValue(validation);
210
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
211
+ const listener = vi.fn();
212
+ act(() => {
213
+ result.current.onExpired(listener);
214
+ });
215
+ await act(async () => {
216
+ await expect(result.current.updateItem([{ id: 'l1', quantity: -1 }])).rejects.toBe(validation);
217
+ });
218
+ expect(getCartCookie()).toBe('cart-1'); // cookie not cleared — cart still good
219
+ expect(listener).not.toHaveBeenCalled();
220
+ });
221
+ });
222
+ // ---------------------------------------------------------------------------
223
+ // Other auto-replay mutations
224
+ // ---------------------------------------------------------------------------
225
+ describe('useCartManager — checkout-flow mutations (auto-replay)', () => {
226
+ it('updateDiscountCodes auto-replays via cartCreate({ discountCodes })', async () => {
227
+ setCartCookie('stale');
228
+ vi.spyOn(activeCartClient, 'updateDiscountCodes').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
229
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
230
+ cart: fakeCart('rec'),
231
+ warnings: [],
232
+ });
233
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
234
+ await act(async () => {
235
+ await result.current.updateDiscountCodes(['SAVE10', 'FREESHIP']);
236
+ });
237
+ expect(createSpy).toHaveBeenCalledWith({ discountCodes: ['SAVE10', 'FREESHIP'] });
238
+ });
239
+ it('updateNote auto-replays via cartCreate({ note })', async () => {
240
+ setCartCookie('stale');
241
+ vi.spyOn(activeCartClient, 'updateNote').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
242
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
243
+ cart: fakeCart('rec'),
244
+ warnings: [],
245
+ });
246
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
247
+ await act(async () => {
248
+ await result.current.updateNote('Gift wrap');
249
+ });
250
+ expect(createSpy).toHaveBeenCalledWith({ note: 'Gift wrap' });
251
+ });
252
+ it('updateBuyerIdentity auto-replays via cartCreate({ buyerIdentity })', async () => {
253
+ setCartCookie('stale');
254
+ vi.spyOn(activeCartClient, 'updateBuyerIdentity').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
255
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
256
+ cart: fakeCart('rec'),
257
+ warnings: [],
258
+ });
259
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
260
+ await act(async () => {
261
+ await result.current.updateBuyerIdentity({ email: 'user@example.com' });
262
+ });
263
+ expect(createSpy).toHaveBeenCalledWith({ buyerIdentity: { email: 'user@example.com' } });
264
+ });
265
+ it('setShippingAddress auto-replays via cartCreate({ shippingAddress })', async () => {
266
+ setCartCookie('stale');
267
+ vi.spyOn(activeCartClient, 'setShippingAddress').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
268
+ const createSpy = vi.spyOn(activeCartClient, 'create').mockResolvedValue({
269
+ cart: fakeCart('rec'),
270
+ warnings: [],
271
+ });
272
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
273
+ const address = {
274
+ firstName: 'Anna',
275
+ lastName: 'Nowak',
276
+ streetLine1: 'ul. Krakowska 1',
277
+ city: 'Warszawa',
278
+ country: 'PL',
279
+ postalCode: '00-001',
280
+ };
281
+ await act(async () => {
282
+ await result.current.setShippingAddress(address);
283
+ });
284
+ expect(createSpy).toHaveBeenCalledWith({ shippingAddress: address });
285
+ });
286
+ });
287
+ // ---------------------------------------------------------------------------
288
+ // getCart — read with auto-clear
289
+ // ---------------------------------------------------------------------------
290
+ describe('useCartManager — getCart', () => {
291
+ it('returns cart on success', async () => {
292
+ setCartCookie('existing');
293
+ vi.spyOn(activeCartClient, 'get').mockResolvedValue(FAKE_CART);
294
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
295
+ let cart;
296
+ await act(async () => {
297
+ cart = await result.current.getCart();
298
+ });
299
+ expect(cart).toEqual(FAKE_CART);
300
+ });
301
+ it('returns null + clears cookie when backend returns CART_NOT_FOUND via userErrors', async () => {
302
+ setCartCookie('stale');
303
+ vi.spyOn(activeCartClient, 'get').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
304
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
305
+ const listener = vi.fn();
306
+ act(() => {
307
+ result.current.onExpired(listener);
308
+ });
309
+ let cart;
310
+ await act(async () => {
311
+ cart = await result.current.getCart();
312
+ });
313
+ expect(cart).toBeNull();
314
+ expect(getCartCookie()).toBeNull();
315
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ reason: 'state-dependent', operation: 'getCart' }));
316
+ });
317
+ it('returns null without fetching when cookie is empty', async () => {
318
+ const getSpy = vi.spyOn(activeCartClient, 'get');
319
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
320
+ let cart;
321
+ await act(async () => {
322
+ cart = await result.current.getCart();
323
+ });
324
+ expect(cart).toBeNull();
325
+ expect(getSpy).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+ // ---------------------------------------------------------------------------
329
+ // onExpired lifecycle + reactive state
330
+ // ---------------------------------------------------------------------------
331
+ describe('useCartManager — onExpired + state', () => {
332
+ it('listener unsubscribe stops further notifications', async () => {
333
+ setCartCookie('stale');
334
+ vi.spyOn(activeCartClient, 'updateItems').mockRejectedValue(recoverableError('CART_NOT_FOUND'));
335
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
336
+ const listener = vi.fn();
337
+ let unsubscribe;
338
+ act(() => {
339
+ unsubscribe = result.current.onExpired(listener);
340
+ });
341
+ act(() => {
342
+ unsubscribe();
343
+ });
344
+ await act(async () => {
345
+ await expect(result.current.updateItem([{ id: 'l1', quantity: 1 }])).rejects.toBeDefined();
346
+ });
347
+ expect(listener).not.toHaveBeenCalled();
348
+ });
349
+ it('isLoading toggles around a mutation', async () => {
350
+ setCartCookie('cart-1');
351
+ let resolveAdd = null;
352
+ const addPromise = new Promise((r) => {
353
+ resolveAdd = r;
354
+ });
355
+ vi.spyOn(activeCartClient, 'addItems').mockImplementation(() => addPromise);
356
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
357
+ expect(result.current.isLoading).toBe(false);
358
+ let pending;
359
+ act(() => {
360
+ pending = result.current.addItem([{ variantId: 'v1' }]);
361
+ });
362
+ // After invocation but before resolve — isLoading should be true on next tick
363
+ await act(async () => {
364
+ await new Promise((r) => setTimeout(r, 0));
365
+ });
366
+ expect(result.current.isLoading).toBe(true);
367
+ await act(async () => {
368
+ resolveAdd({ cart: FAKE_CART, warnings: [] });
369
+ await pending;
370
+ });
371
+ expect(result.current.isLoading).toBe(false);
372
+ });
373
+ it('error state is populated on failure and cleared on subsequent success', async () => {
374
+ setCartCookie('cart-1');
375
+ const fail = new Error('boom');
376
+ const addSpy = vi.spyOn(activeCartClient, 'addItems')
377
+ .mockRejectedValueOnce(fail)
378
+ .mockResolvedValueOnce({ cart: FAKE_CART, warnings: [] });
379
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
380
+ await act(async () => {
381
+ await expect(result.current.addItem([{ variantId: 'v1' }])).rejects.toBe(fail);
382
+ });
383
+ expect(result.current.error).toBe('boom');
384
+ await act(async () => {
385
+ await result.current.addItem([{ variantId: 'v1' }]);
386
+ });
387
+ expect(result.current.error).toBeNull();
388
+ expect(addSpy).toHaveBeenCalledTimes(2);
389
+ });
390
+ it('clearCart removes the cookie', async () => {
391
+ setCartCookie('to-clear');
392
+ const { result } = renderHook(() => useCartManager(), { wrapper: Wrapper });
393
+ expect(result.current.getCartId()).toBe('to-clear');
394
+ act(() => {
395
+ result.current.clearCart();
396
+ });
397
+ expect(getCartCookie()).toBeNull();
398
+ expect(result.current.getCartId()).toBeNull();
399
+ });
400
+ });
@@ -22,7 +22,7 @@
22
22
  * ```
23
23
  */
24
24
  import type { StorefrontClient } from '../client/types';
25
- import type { Cart, CartCreateInput, CartLineInput, CartLineUpdateInput, CartBuyerIdentityInput, CartSetShippingAddressInput, CartSetBillingAddressInput, CartSelectShippingMethodInput, CartSelectPaymentMethodInput, CartApplyGiftCardInput, CartRemoveGiftCardInput, CartUpdateGiftCardRecipientInput, CartCompleteInput, CartWarning, Order, DiscountValidationResult } from './types';
25
+ import type { Cart, CartCreateInput, CartLineInput, CartLineUpdateInput, CartBuyerIdentityInput, CartSetShippingAddressInput, CartSetBillingAddressInput, CartSelectShippingMethodInput, CartSelectPaymentMethodInput, CartApplyGiftCardInput, CartRemoveGiftCardInput, CartUpdateGiftCardRecipientInput, CartCompleteInput, CartWarning, Order, DiscountValidationResult, PaymentSession, PaymentCreateInput } from './types';
26
26
  /**
27
27
  * Standard mutation return shape — `cart` is non-null on success (userErrors
28
28
  * cause assertNoUserErrors to throw), `warnings` may be empty.
@@ -130,6 +130,24 @@ export declare class CartClient {
130
130
  * `paymentCreate` mutation after checking `order.canCreatePayment`.
131
131
  */
132
132
  complete(input: CartCompleteInput): Promise<CartCompleteOutcome>;
133
+ /**
134
+ * Initiate a payment session for an order created by `complete()`. Call this
135
+ * when `order.canCreatePayment` is `true` — orders with an offline payment
136
+ * method (cash on delivery, manual bank transfer) skip this step.
137
+ *
138
+ * `input.returnUrl` / `input.cancelUrl` are optional; when supplied they must
139
+ * point to a verified domain of the shop (the server rejects others). The
140
+ * returned `PaymentSession` is flow-aware — branch on `session.flow`:
141
+ * `ONLINE_REDIRECT` → redirect to `session.redirectUrl`, `ONLINE_EMBEDDED` →
142
+ * render a widget with `session.clientSecret`, `INSTANT_DIRECT` → already
143
+ * settled, read `session.status`.
144
+ *
145
+ * Idempotent — calling it again for the same order returns the existing
146
+ * still-valid session instead of creating a duplicate (safe to retry).
147
+ * Throws a `StorefrontError` (carrying `userErrors`) when the order can't be
148
+ * paid — inspect `.userErrors[0].code` (`PaymentErrorCode`).
149
+ */
150
+ createPayment(input: PaymentCreateInput): Promise<PaymentSession>;
133
151
  /**
134
152
  * Validate discount code preview (Decision D3) — read-only Query, NIE
135
153
  * mutation. Doesn't modify cart state. Returns `{ isValid, discount?, error? }`
@@ -1 +1 @@
1
- {"version":3,"file":"cart-client.d.ts","sourceRoot":"","sources":["../../../src/core/cart/cart-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EACV,IAAI,EACJ,eAAe,EACf,aAAa,EACb,mBAAmB,EACnB,sBAAsB,EACtB,2BAA2B,EAC3B,0BAA0B,EAC1B,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,uBAAuB,EACvB,gCAAgC,EAChC,iBAAiB,EACjB,WAAW,EACX,KAAK,EACL,wBAAwB,EACzB,MAAM,SAAS,CAAC;AA8CjB;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,WAAW,EAAE,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,KAAK,CAAC;IACb,QAAQ,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,qBAAa,UAAU;IACT,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,gBAAgB;IAErD;;;OAGG;IACG,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAQ/C;;OAEG;IACG,MAAM,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASnE;;OAEG;IACG,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASpF;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAS7F;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASlF;;;OAGG;IACG,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAShG;;OAEG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAS5E;;OAEG;IACG,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,sBAAsB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAa9G;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE,2BAA2B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAS1F;;;OAGG;IACG,iBAAiB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASxF;;;OAGG;IACG,oBAAoB,CAAC,KAAK,EAAE,6BAA6B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAS9F;;;OAGG;IACG,mBAAmB,CAAC,KAAK,EAAE,4BAA4B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAS5F;;;OAGG;IACG,aAAa,CAAC,KAAK,EAAE,sBAAsB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAShF;;;OAGG;IACG,cAAc,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASlF;;;;OAIG;IACG,uBAAuB,CAAC,KAAK,EAAE,gCAAgC,GAAG,OAAO,CAAC,mBAAmB,CAAC;IASpG;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAYtE;;;;;;;;OAQG;IACG,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC;CAOpG"}
1
+ {"version":3,"file":"cart-client.d.ts","sourceRoot":"","sources":["../../../src/core/cart/cart-client.ts"],"names":[],"mappings":"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"}
@@ -22,7 +22,7 @@
22
22
  * ```
23
23
  */
24
24
  import { assertNoUserErrors } from '../helpers/assert-no-user-errors';
25
- import { CART_QUERY, CART_CREATE, CART_ADD_LINES, CART_UPDATE_LINES, CART_REMOVE_LINES, CART_DISCOUNT_CODES_UPDATE, CART_UPDATE_NOTE, CART_UPDATE_BUYER_IDENTITY, CART_SET_SHIPPING_ADDRESS, CART_SET_BILLING_ADDRESS, CART_SELECT_SHIPPING_METHOD, CART_SELECT_PAYMENT_METHOD, CART_APPLY_GIFT_CARD, CART_REMOVE_GIFT_CARD, CART_UPDATE_GIFT_CARD_RECIPIENT, CART_COMPLETE, CART_VALIDATE_DISCOUNT_CODE, } from '../operations/cart';
25
+ import { CART_QUERY, CART_CREATE, CART_ADD_LINES, CART_UPDATE_LINES, CART_REMOVE_LINES, CART_DISCOUNT_CODES_UPDATE, CART_UPDATE_NOTE, CART_UPDATE_BUYER_IDENTITY, CART_SET_SHIPPING_ADDRESS, CART_SET_BILLING_ADDRESS, CART_SELECT_SHIPPING_METHOD, CART_SELECT_PAYMENT_METHOD, CART_APPLY_GIFT_CARD, CART_REMOVE_GIFT_CARD, CART_UPDATE_GIFT_CARD_RECIPIENT, CART_COMPLETE, CART_VALIDATE_DISCOUNT_CODE, PAYMENT_CREATE, } from '../operations/cart';
26
26
  export class CartClient {
27
27
  client;
28
28
  constructor(client) {
@@ -181,6 +181,28 @@ export class CartClient {
181
181
  warnings: data.cartComplete.warnings ?? [],
182
182
  };
183
183
  }
184
+ /**
185
+ * Initiate a payment session for an order created by `complete()`. Call this
186
+ * when `order.canCreatePayment` is `true` — orders with an offline payment
187
+ * method (cash on delivery, manual bank transfer) skip this step.
188
+ *
189
+ * `input.returnUrl` / `input.cancelUrl` are optional; when supplied they must
190
+ * point to a verified domain of the shop (the server rejects others). The
191
+ * returned `PaymentSession` is flow-aware — branch on `session.flow`:
192
+ * `ONLINE_REDIRECT` → redirect to `session.redirectUrl`, `ONLINE_EMBEDDED` →
193
+ * render a widget with `session.clientSecret`, `INSTANT_DIRECT` → already
194
+ * settled, read `session.status`.
195
+ *
196
+ * Idempotent — calling it again for the same order returns the existing
197
+ * still-valid session instead of creating a duplicate (safe to retry).
198
+ * Throws a `StorefrontError` (carrying `userErrors`) when the order can't be
199
+ * paid — inspect `.userErrors[0].code` (`PaymentErrorCode`).
200
+ */
201
+ async createPayment(input) {
202
+ const data = await this.client.mutate(PAYMENT_CREATE, { input });
203
+ assertNoUserErrors(data.paymentCreate);
204
+ return data.paymentCreate.payment;
205
+ }
184
206
  /**
185
207
  * Validate discount code preview (Decision D3) — read-only Query, NIE
186
208
  * mutation. Doesn't modify cart state. Returns `{ isValid, discount?, error? }`