@doswiftly/storefront-operations 16.1.0 → 18.0.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,885 @@
1
1
  # Changelog
2
2
 
3
+ ## 18.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 1d51cc7: Schema MAJOR: nine String/`[String]` image URL fields replaced with `Image`/`[Image!]!`. Every storefront-facing image field now supports `Image.url(transform: ...)` and multi-tenant CDN signing on shop-owned media. Migration touches return, brand, shipping carrier, loyalty benefit, product review, attribute swatch, and payment surfaces.
8
+
9
+ **Why**: This is the final batch of the image-transform pipeline sweep. Previously these nine fields returned a raw string (or list of strings), so storefronts could not request specific sizes, formats, or crops. After this change, every image-bearing field on the storefront API exposes the same `Image` shape as `Product` and `ProductVariant` — pass an `ImageTransformInput` to the `url` argument to get a responsive variant, and rely on imgproxy signing where the merchant uploaded media to our CDN.
10
+
11
+ **Schema diff (9 fields)**:
12
+
13
+ | Type | Before | After |
14
+ | ------------------------- | ----------------------- | ------------------- |
15
+ | `ReturnLineItem` | `imageUrl: String` | `image: Image` |
16
+ | `Brand` | `logo: String` | `logo: Image` |
17
+ | `BrandFilterValue` | `logo: String` | `logo: Image` |
18
+ | `ShippingCarrier` | `logoUrl: String` | `logo: Image` |
19
+ | `TierBenefit` | `icon: String` | `icon: Image` |
20
+ | `ProductReview` | `images: [String]` | `images: [Image!]!` |
21
+ | `AttributeSwatch` | `imageUrl: String` | `image: Image` |
22
+ | `PaymentMethod` | `icon: String` | `icon: Image` |
23
+ | `PaymentMethodInstrument` | `brandImageUrl: String` | `brandImage: Image` |
24
+
25
+ **Migration — pick a thumbnail fragment and replace selections**:
26
+
27
+ The storefront-operations package ships an `ImageThumbnail` fragment (`{ id, url(transform: { maxWidth: 300 }), altText, width, height, thumbhash }`) plus `ImageCard` (maxWidth 800) and `ImageFull` (maxWidth 1600). Pick the size that matches where you render the image.
28
+
29
+ ```diff
30
+ fragment ReturnItem on ReturnItem {
31
+ id
32
+ variantTitle
33
+ - imageUrl
34
+ + image {
35
+ + ...ImageThumbnail
36
+ + }
37
+ }
38
+
39
+ fragment ShippingCarrier on ShippingCarrier {
40
+ id
41
+ name
42
+ - logoUrl
43
+ + logo {
44
+ + ...ImageThumbnail
45
+ + }
46
+ serviceCode
47
+ }
48
+
49
+ fragment AttributeSwatch on AttributeSwatch {
50
+ colorHex
51
+ - imageUrl
52
+ + image {
53
+ + ...ImageThumbnail
54
+ + }
55
+ }
56
+
57
+ fragment ProductReview on ProductReview {
58
+ id
59
+ rating
60
+ - images
61
+ + images {
62
+ + ...ImageThumbnail
63
+ + }
64
+ }
65
+
66
+ fragment LoyaltyTier on LoyaltyTier {
67
+ id
68
+ name
69
+ customBenefits {
70
+ name
71
+ description
72
+ - icon
73
+ + icon {
74
+ + ...ImageThumbnail
75
+ + }
76
+ }
77
+ }
78
+
79
+ fragment PaymentMethod on PaymentMethod {
80
+ id
81
+ name
82
+ type
83
+ - icon
84
+ + icon {
85
+ + ...ImageThumbnail
86
+ + }
87
+ }
88
+
89
+ fragment PaymentMethodInstrument on PaymentMethodInstrument {
90
+ providerCode
91
+ instrumentCode
92
+ displayName
93
+ - brandImageUrl
94
+ + brandImage {
95
+ + ...ImageThumbnail
96
+ + }
97
+ enabled
98
+ }
99
+
100
+ # Brand (used inside MenuItem.resource union and as Product.brand)
101
+ {
102
+ brand {
103
+ id
104
+ handle
105
+ name
106
+ - logo
107
+ + logo {
108
+ + ...ImageThumbnail
109
+ + }
110
+ }
111
+ }
112
+
113
+ # BrandFilterValue (used inside AvailableFilters.brands)
114
+ {
115
+ brands {
116
+ id
117
+ handle
118
+ name
119
+ - logo
120
+ + logo {
121
+ + ...ImageThumbnail
122
+ + }
123
+ productCount
124
+ }
125
+ }
126
+ ```
127
+
128
+ **Field renames** (additional to type changes):
129
+ - `ShippingCarrier.logoUrl` → `ShippingCarrier.logo`
130
+ - `AttributeSwatch.imageUrl` → `AttributeSwatch.image`
131
+ - `ReturnLineItem.imageUrl` → `ReturnLineItem.image`
132
+ - `PaymentMethodInstrument.brandImageUrl` → `PaymentMethodInstrument.brandImage`
133
+
134
+ Rename + type change happen in the same release; you have to update both the field name and the selection set.
135
+
136
+ **Inline transform alternative**: if you do not want to use `ImageThumbnail`, you can request the URL with an explicit transform — the result is identical:
137
+
138
+ ```graphql
139
+ {
140
+ carrier {
141
+ logo {
142
+ url(transform: { maxWidth: 64, preferredContentType: WEBP })
143
+ altText
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ **Migration checklist**:
150
+ - [ ] Update every fragment listed above so the selection set requests `url`/`altText`/etc. instead of treating the field as a scalar.
151
+ - [ ] Rename selections for the four fields that changed name (`logoUrl`/`imageUrl`/`brandImageUrl`).
152
+ - [ ] Re-run codegen so your generated TypedDocumentNode bindings reflect the new shape.
153
+ - [ ] If your UI rendered the old string URL directly, switch to consuming `field.url` (and optionally `field.altText`); pick the transform size that matches your component.
154
+ - [ ] `ProductReview.images` is now a non-null list of non-null `Image` values — drop any defensive null handling around individual elements but keep the empty-array case.
155
+
156
+ **No backend-input breakage**: `ReviewCreateInput.images` still takes `[String]` URLs from the customer at submission time. Only the response field changed shape.
157
+
158
+ - e28e902: Payment schema naming overhaul — one coherent provider / method / instrument model.
159
+
160
+ **Why**: payment fields mixed three concepts under inconsistent names — a `ProviderCode` enum, a `PaymentMethodInstrument` object whose fields were `instrumentCode` / `providerCode`, and `*Code`-suffixed mutation inputs. Every payment field now follows a single convention: **provider** (who processes the payment — `PAYU`, `PRZELEWY24`), **method** (the category the buyer picks — `BLIK`, `CARD`), **instrument** (the concrete option within a method — a specific BLIK code or bank deep-link).
161
+
162
+ **Breaking**:
163
+ 1. Enum `ProviderCode` → `PaymentProvider` (values unchanged: `PAYU`, `PRZELEWY24`, `STRIPE`, ...).
164
+ 2. Object `PaymentMethodInstrument` → `PaymentInstrument`; field `instrumentCode` → `code`, field `providerCode` → `provider`.
165
+ 3. Enums `PaymentMethodInstrumentType` → `PaymentInstrumentType`, `PaymentMethodInstrumentDisplayHint` → `PaymentInstrumentDisplayHint`.
166
+ 4. `Cart.selectedPaymentInstrumentCode` → `Cart.selectedPaymentInstrument`.
167
+ 5. `CartSelectPaymentMethodInput`: `preferredProviderCode` → `preferredProvider`, `preferredInstrumentCode` → `preferredInstrument`.
168
+ 6. `CartSelectPaymentMethodInput.preferredProvider` is now the `PaymentProvider` enum (UPPERCASE: `PAYU`, `PRZELEWY24`, ...) instead of a lowercase `String`. It matches the output — `PaymentInstrument.provider` and `PaymentMethod.preferredProvider` are already `PaymentProvider` — so you read the value off the API and pass it straight back, no case conversion. The schema now self-documents the selectable gateways. (`preferredInstrument` stays `String` — instrument codes are gateway-specific.)
169
+
170
+ **Usage example**:
171
+
172
+ ```graphql
173
+ # before
174
+ fragment Instrument on PaymentMethodInstrument {
175
+ providerCode
176
+ instrumentCode
177
+ displayName
178
+ }
179
+ mutation Select($input: CartSelectPaymentMethodInput!) {
180
+ cartSelectPaymentMethod(input: $input) {
181
+ cart {
182
+ selectedPaymentInstrumentCode
183
+ }
184
+ }
185
+ }
186
+ # $input: { cartId, methodType: BLIK, preferredProviderCode: "payu", preferredInstrumentCode: "blik" }
187
+
188
+ # after
189
+ fragment Instrument on PaymentInstrument {
190
+ provider
191
+ code
192
+ displayName
193
+ }
194
+ mutation Select($input: CartSelectPaymentMethodInput!) {
195
+ cartSelectPaymentMethod(input: $input) {
196
+ cart {
197
+ selectedPaymentInstrument
198
+ }
199
+ }
200
+ }
201
+ # $input: { cartId, methodType: BLIK, preferredProvider: PAYU, preferredInstrument: "blik" }
202
+ # ^ preferredProvider is the PaymentProvider enum (UPPERCASE) — read it from
203
+ # `instrument.provider` / `method.preferredProvider` and pass it back verbatim.
204
+ ```
205
+
206
+ ```ts
207
+ // SDK types — before
208
+ import type {
209
+ PaymentMethodInstrument,
210
+ ProviderCode,
211
+ } from "@doswiftly/storefront-sdk";
212
+ const code = instrument.instrumentCode;
213
+ const who = instrument.providerCode;
214
+
215
+ // after
216
+ import type {
217
+ PaymentInstrument,
218
+ PaymentProvider,
219
+ } from "@doswiftly/storefront-sdk";
220
+ const code = instrument.code;
221
+ const who = instrument.provider;
222
+ ```
223
+
224
+ The pre-built `<PaymentInstrumentSection>` / `<PaymentInstrumentTile>` component props (`selectedInstrumentCode`, `onSelectInstrument`) are unchanged — only the instrument data shape (`.code`) changed.
225
+
226
+ **Migration checklist for existing storefronts**:
227
+ - [ ] Rename type references: `PaymentMethodInstrument` → `PaymentInstrument`, `ProviderCode` → `PaymentProvider`, `PaymentMethodInstrumentType`/`PaymentMethodInstrumentDisplayHint` → `PaymentInstrumentType`/`PaymentInstrumentDisplayHint`
228
+ - [ ] Field access: `instrument.instrumentCode` → `instrument.code`, `instrument.providerCode` → `instrument.provider`
229
+ - [ ] Cart selection: `cart.selectedPaymentInstrumentCode` → `cart.selectedPaymentInstrument`
230
+ - [ ] Mutation input: `preferredProviderCode` → `preferredProvider`, `preferredInstrumentCode` → `preferredInstrument`
231
+ - [ ] Mutation input value: pass the `PaymentProvider` enum (UPPERCASE) to `preferredProvider`, not a lowercase string — `preferredProvider: "payu"` → `preferredProvider: PAYU`. Drop any `.toLowerCase()` you applied to `instrument.provider` before sending it back.
232
+ - [ ] If you hand-write GraphQL operations: update fragments + re-run codegen against the published `schema.graphql`
233
+
234
+ - 159184c: `ShipmentItem.imageUrl: String` replaced with `ShipmentItem.image: Image`.
235
+
236
+ **Why**: The previous `imageUrl` field returned a raw URL without transform support and was unreachable due to a resolver mapping bug (snapshot field names did not match what the storefront-facing mapper expected). The new `image: Image` field aligns shipment item images with `Product` and `ProductVariant` patterns — storefronts can request specific sizes, formats, and crops via `image { url(transform: { maxWidth: 200, preferredContentType: WEBP }) }`.
237
+
238
+ **Image source**: live variant image with snapshot fallback. The variant is loaded per-request via DataLoader (no N+1 cost when rendering many shipment items at once), and the resolved URL passes through the storefront's image transform pipeline so the requested dimensions/format are honored. When the variant has been deleted since the shipment was created, `image` returns `null` — render a placeholder client-side. The `title`, `variantTitle`, and `sku` fields stay as immutable snapshots from the time of fulfillment, so the tracking page still shows what was shipped even after a product is removed from the catalog.
239
+
240
+ **Migration**:
241
+
242
+ ```diff
243
+ fragment ShipmentItem on ShipmentItem {
244
+ id
245
+ title
246
+ variantTitle
247
+ sku
248
+ quantity
249
+ - imageUrl
250
+ + image {
251
+ + ...ImageThumbnail
252
+ + }
253
+ }
254
+ ```
255
+
256
+ Or, if you prefer to inline the transform without using the `ImageThumbnail` fragment:
257
+
258
+ ```diff
259
+ fragment ShipmentItem on ShipmentItem {
260
+ id
261
+ title
262
+ variantTitle
263
+ sku
264
+ quantity
265
+ - imageUrl
266
+ + image {
267
+ + url(transform: { maxWidth: 200, preferredContentType: WEBP })
268
+ + altText
269
+ + }
270
+ }
271
+ ```
272
+
273
+ **Migration checklist**:
274
+ - [ ] Replace `imageUrl` selections in your `ShipmentItem` fragments / inline queries with `image { ...ImageThumbnail }` (or inline transform).
275
+ - [ ] Handle the `image: null` case in your ShipmentItem rendering (variant deleted after fulfillment — render a placeholder).
276
+ - [ ] Re-run codegen so your TypedDocumentNode bindings reflect the new shape.
277
+ - [ ] No SDK code changes are required beyond the regenerated operation types — the linked SDK bump exists to keep `@doswiftly/storefront-sdk` in version parity with `@doswiftly/storefront-operations`.
278
+
279
+ - cc7edbe: SDK-BFF customer authentication — same-origin route handlers + automatic session refresh.
280
+
281
+ **Why**: Customer auth now runs entirely same-origin on your storefront domain through a backend-for-frontend layer. The browser never calls the commerce API for auth, and the refresh token never reaches JavaScript — it lives in a first-party httpOnly cookie read only server-side and exchanged server-to-server. This behaves identically on a platform subdomain, a custom domain, and off-platform hosting (e.g. Vercel), because the route handlers live on your own domain rather than depending on any edge layer.
282
+
283
+ **New**
284
+ - `createStorefrontAuthRoute({ apiUrl, shopSlug, isTrustedOrigin? })` (from `@doswiftly/storefront-sdk/react/server`) generates the `login` / `refresh` / `logout` / `whoami` route handlers. Mount once:
285
+
286
+ ```ts
287
+ // app/api/auth/[action]/route.ts
288
+ import {
289
+ createStorefrontAuthRoute,
290
+ trustedForwardedHostValidator,
291
+ } from "@doswiftly/storefront-sdk/react/server";
292
+
293
+ export const { GET, POST } = createStorefrontAuthRoute({
294
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
295
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
296
+ isTrustedOrigin: trustedForwardedHostValidator, // when running behind a reverse proxy
297
+ });
298
+ ```
299
+
300
+ - `getInitialAuth()` (server-only) reads the first-party cookies and returns `{ isAuthenticated, accessToken, expiresAt }` to seed the provider on the first render — no flash of signed-out UI, no extra round-trip:
301
+
302
+ ```tsx
303
+ // app/layout.tsx (Server Component)
304
+ const { isAuthenticated, accessToken, expiresAt } = await getInitialAuth();
305
+ return (
306
+ <StorefrontProvider
307
+ initialIsAuthenticated={isAuthenticated}
308
+ initialAccessToken={accessToken}
309
+ initialExpiresAt={expiresAt}
310
+ /* ...config, shopData */
311
+ >
312
+ {children}
313
+ </StorefrontProvider>
314
+ );
315
+ ```
316
+
317
+ - `AuthClient.refreshSession()` refreshes the session via the same-origin `POST /api/auth/refresh`, returning `{ accessToken, expiresAt }`. The proactive scheduler (`autoRefresh`, on by default in the browser) and the reactive 401 retry now use it — so an already-expired access token still refreshes silently.
318
+
319
+ **Breaking**
320
+ 1. Automatic refresh (the proactive scheduler and the 401 retry) now goes through `POST /api/auth/refresh` instead of a GraphQL refresh. You **must** mount `createStorefrontAuthRoute` for refresh to work.
321
+ 2. `useSessionRefresh` no longer accepts an `authBasePath` option — the base path comes from the provider-configured auth client.
322
+ 3. The refresh cookie is scoped to the auth base path (so logout can revoke the session), and the readable session-expiry hint cookie is long-lived (so a returning visitor is not shown as signed-out on a cold start).
323
+
324
+ **Migration checklist**
325
+ - [ ] Add `app/api/auth/[action]/route.ts` exporting `createStorefrontAuthRoute(...)`.
326
+ - [ ] Seed the provider from `getInitialAuth()` in your root layout (Server Component).
327
+ - [ ] Remove any `authBasePath` argument passed to `useSessionRefresh`.
328
+ - [ ] Subscribe with `useSessionExpired(cb)` to react globally (notice + redirect to sign-in).
329
+ - [ ] Behind a reverse proxy that rewrites `Host`, pass `isTrustedOrigin: trustedForwardedHostValidator`.
330
+
331
+ **Standards reference**: RFC 6265 (cookie path-matching), RFC 9700 (OAuth 2.0 security best current practice — token endpoints are public; security is protocol-level, not network allowlists).
332
+
333
+ `@doswiftly/storefront-operations` is version-synced with this release (linked pair); it has no operation changes in this entry.
334
+
335
+ ### Minor Changes
336
+
337
+ - 9969bdd: Keep customer sessions alive automatically — proactive refresh + session-expiry awareness.
338
+
339
+ **Why**: an active buyer should never be logged out mid-session. The SDK now renews the access token for you, ahead of expiry, and exposes a single global signal for when a session truly cannot be saved.
340
+
341
+ **Additive (backward-compatible)**:
342
+ 1. **Automatic proactive refresh** — on by default in the browser (off on the server): `<StorefrontProvider>` renews the access token shortly before it expires, reschedules from each new expiry, and recovers on tab wake. Nothing to pass and nothing to copy into your app; opt out with `autoRefresh={false}`. This is the primary protection — in the happy path the token is renewed before it ever expires.
343
+ 2. **Global session-expired signal** — `useSessionExpired((event) => { ... })` fires when the session cannot be renewed (a refresh failed on tab wake, or a reactive refresh after a 401 also failed). Use it to show a notice and redirect to sign-in. The new `ErrorCodes.SESSION_EXPIRED` names the condition.
344
+ 3. **Reactive recovery on a 401** — if a read request comes back with HTTP 401, the SDK runs a single, deduped refresh and replays the request automatically; a state-changing request never auto-retries — it bails and emits the session-expired signal instead.
345
+ 4. **Session expiry in state** — the auth store now tracks `expiresAt` (ISO 8601), populated from login, refresh and the current-customer query; the access token stays in memory only and `expiresAt` is never persisted. A new `sessionExpiresAt` field is available on the `Customer` type, and `<StorefrontProvider initialExpiresAt>` seeds it during server rendering for an instant cold-start schedule.
346
+
347
+ **Usage**:
348
+
349
+ ```tsx
350
+ // works out of the box — proactive refresh is on by default in the browser
351
+ // (nothing to pass; opt out with autoRefresh={false})
352
+ <StorefrontProvider config={config} shopData={shopData}>
353
+ {children}
354
+ </StorefrontProvider>;
355
+
356
+ // anywhere near the root — react globally when a session could not be saved
357
+ useSessionExpired(() => {
358
+ toast("Your session expired — please sign in again.");
359
+ router.push("/login");
360
+ });
361
+ ```
362
+
363
+ **Migration**: none — everything is additive. `autoRefresh` is opt-out (default-on in the browser) and existing manual refresh flows keep working. The proactive refresh syncs the renewed token through the same `/api/auth/set-token` route you already use for login — no extra wiring. Point it elsewhere with `authBasePath` if your auth routes are mounted on a different base path.
364
+
365
+ ```
366
+
367
+ ```
368
+
369
+ - 5525fd4: Version sync with `@doswiftly/storefront-sdk` (linked release group). No code change in this package — see the `@doswiftly/storefront-sdk` changelog for the additive `debug: 'verbose'` mode and `DebugOptions` granular controls.
370
+ - 5525fd4: Version sync with `@doswiftly/storefront-sdk` (linked release group). No code change in this package — see the `@doswiftly/storefront-sdk` changelog for the additive runtime const exports of schema enums.
371
+ - 9b62091: `PaymentMethodType` exposes two additional categories that the platform already supports server-side: `WALLET` (Apple Pay / Google Pay / Visa Mobile) and `INSTALLMENT` (Klarna, PayPo, Twisto, Alior Raty). Storefront code can now branch on them without falling through to `OTHER`.
372
+
373
+ **Why** — the GraphQL `PaymentMethodType` enum had drifted away from the underlying domain enum: PayU and Przelewy24 adapters were already mapping payByLink / method names to `WALLET` and `INSTALLMENT` (Apple Pay, Google Pay, BNPL providers), but the public GraphQL enum only declared `CARD / BLIK / BANK_TRANSFER / CASH_ON_DELIVERY / OTHER`. A storefront calling `getAvailablePaymentMethods` against a shop with Apple Pay enabled would crash with `Enum "PaymentMethodType" cannot represent value: "WALLET"`. The enum is now sourced from a single domain definition — drift between the platform's internal enum and the public GraphQL enum is structurally impossible going forward.
374
+
375
+ **Additive (backward-compatible)**:
376
+
377
+ Two new runtime values exposed by the const + type alias (per `enumsAsConst` codegen):
378
+
379
+ ```ts
380
+ import { PaymentMethodType } from "@doswiftly/storefront-sdk";
381
+
382
+ PaymentMethodType.Wallet; // 'WALLET'
383
+ PaymentMethodType.Installment; // 'INSTALLMENT'
384
+
385
+ Object.values(PaymentMethodType);
386
+ // ['BANK_TRANSFER', 'BLIK', 'CARD', 'CASH_ON_DELIVERY', 'INSTALLMENT', 'OTHER', 'WALLET']
387
+ ```
388
+
389
+ The TypeScript union type widens correspondingly to include `'WALLET' | 'INSTALLMENT'`. Existing code that handled `'OTHER'` as a catch-all keeps working — methods previously falling through to `OTHER` now report their specific category, and any exhaustive switch on `PaymentMethodType` gets a compile error pointing at the missing case.
390
+
391
+ **Usage example** — render brand-correct iconography for wallet checkouts:
392
+
393
+ ```tsx
394
+ import { PaymentMethodType } from "@doswiftly/storefront-sdk";
395
+
396
+ function PaymentMethodIcon({ type }: { type: PaymentMethodType }) {
397
+ switch (type) {
398
+ case PaymentMethodType.Card:
399
+ return <CardIcon />;
400
+ case PaymentMethodType.Blik:
401
+ return <BlikIcon />;
402
+ case PaymentMethodType.BankTransfer:
403
+ return <BankIcon />;
404
+ case PaymentMethodType.Wallet:
405
+ return <WalletIcon />; // NEW — Apple Pay / Google Pay
406
+ case PaymentMethodType.Installment:
407
+ return <InstallmentIcon />; // NEW — Klarna / PayPo / Twisto
408
+ case PaymentMethodType.CashOnDelivery:
409
+ return <CashIcon />;
410
+ case PaymentMethodType.Other:
411
+ return <GenericPaymentIcon />;
412
+ }
413
+ }
414
+ ```
415
+
416
+ **Migration checklist** — none required for storefronts that already routed `OTHER` to a generic icon:
417
+ - [ ] (Optional) Add explicit branches for `Wallet` / `Installment` in payment method UI to render brand-correct iconography (Apple Pay logo, Klarna logo, etc.) instead of the generic fallback.
418
+ - [ ] (Optional) If you ran exhaustive `switch (method.type)` blocks asserted with `never`-check, add the two new cases — the compiler points at them on the next build.
419
+
420
+ **Standards reference** — Apple Pay / Google Pay are typically classified under "digital wallet" payment methods in industry catalogues (Stripe, Adyen, Braintree all use `wallet` as a top-level category). `INSTALLMENT` covers Buy-Now-Pay-Later providers that present multi-instalment options at checkout.
421
+
422
+ - 78bd561: Version sync with `@doswiftly/storefront-sdk` (linked release group). No code change in this package — see the `@doswiftly/storefront-sdk` changelog for the additive `<StorefrontProvider initialAccessToken>` prop and `authStore.setAuth` signature relax.
423
+ - 5525fd4: Version sync with `@doswiftly/storefront-sdk` (linked release group). No code change in this package — see the `@doswiftly/storefront-sdk` changelog for the additive `useCartManager` checkout suite and `complete()` auto-cleanup.
424
+ - 468f70b: Version sync with `@doswiftly/storefront-sdk` (linked release group). No code change in this package — see the `@doswiftly/storefront-sdk` changelog for the additive `useCartManager({ initialCartId })` server-known seed.
425
+
426
+ ### Patch Changes
427
+
428
+ - 9bac7e9: Fix stale field names in the `CartSelectPaymentMethodInput` description. The input's description still referenced `preferredProviderCode` / `preferredInstrumentCode` after those fields were renamed to `preferredProvider` / `preferredInstrument`. Description-only correction — field names, types and behavior are unchanged.
429
+ - 9857460: Polished all GraphQL schema descriptions to consistent professional English (documentation only — no contract changes).
430
+ - a5a3f4b: Image transform pipeline extended to shop-owned content surfaces: `BlogCategory.image`, `BlogPost.featuredImage`, `LoyaltyReward.image`, and `MenuItem.image`.
431
+
432
+ These four fields already accepted an `Image.url(transform: ...)` selection, but the URL was not threaded through the storefront CDN context, so any URL stored as a relative path (rather than a full https URL) skipped the imgproxy signing step. Now all four resolvers build the CDN context per query and pass it to `mapImageToGraphQL`, so transform-aware URLs honor multi-tenant signing on shop-owned media.
433
+
434
+ No schema changes. Selections that already used `image { url(transform: ...) }` keep working — the only behavior change is that URLs stored as relative paths are now CDN-signed before transforms apply. URLs stored as absolute https values keep returning unchanged (the helper falls back to the raw value when there is no path component).
435
+
436
+ **What this enables**:
437
+
438
+ ```graphql
439
+ query BlogList {
440
+ blogPosts(first: 10) {
441
+ edges {
442
+ node {
443
+ title
444
+ featuredImage {
445
+ url(transform: { maxWidth: 800, preferredContentType: WEBP })
446
+ altText
447
+ }
448
+ }
449
+ }
450
+ }
451
+ }
452
+ ```
453
+
454
+ The same `image { url(transform: ...) }` pattern is also valid on `BlogCategory`, `LoyaltyReward`, and `MenuItem`.
455
+
456
+ **No migration required.**
457
+
458
+ - 0c0205d: Image transform support extended to `Cart.lines[].variant.image`, `Order.lineItems[].variant.image`, `Collection.image`, and `Category.image`.
459
+
460
+ Previously these four image fields returned a plain object that did not pass through the storefront image pipeline, so calling `image { url(transform: { maxWidth: 200, preferredContentType: WEBP }) }` silently returned a raw URL without applying the requested size/format. Now all four resolvers map images through the same transform pipeline used by `Product` and `ProductVariant`, so `Image.url(transform: ...)` honors the requested dimensions and format.
461
+
462
+ No schema changes — selections that already used `image { url }` keep working and start receiving the transform-aware URL when a transform argument is supplied. Selections that already used `image { url(transform: ...) }` now apply the transform end-to-end.
463
+
464
+ **What you can do now**:
465
+
466
+ ```graphql
467
+ query CartThumbnails {
468
+ cart {
469
+ lines {
470
+ edges {
471
+ node {
472
+ variant {
473
+ image {
474
+ url(transform: { maxWidth: 200, preferredContentType: WEBP })
475
+ altText
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+ ```
484
+
485
+ The same `image { url(transform: ...) }` pattern is now valid on `Order.lineItems[].variant`, `Collection.image`, and `Category.image`.
486
+
487
+ **No migration required** — existing storefronts continue to work. To take advantage of transforms on these surfaces, switch hardcoded sizes in your `<img src>` rendering to a parametrized `url(transform: ...)` selection.
488
+
489
+ - 9c55b39: Export the payment enums `PaymentProvider`, `PaymentInstrumentType`, `PaymentInstrumentDisplayHint` and `PaymentMethodUnavailableReason` from the package root.
490
+
491
+ **Why**: these enums were already generated but only reachable via a deep import path, which reads as private API. They are now re-exported from the package entry point alongside the other schema enums (`PaymentMethodType`, `DeliveryType`, ...), so you can type a wrapper around `cartSelectPaymentMethod` — whose `preferredProvider` input is the `PaymentProvider` enum — without reaching into internals.
492
+
493
+ **Additive (backward-compatible)**: each enum is both a runtime value (`Object.values(PaymentProvider)`) and a type (`import type { PaymentProvider }`). No existing import changes.
494
+
495
+ **Usage example**:
496
+
497
+ ```ts
498
+ import { PaymentProvider } from "@doswiftly/storefront-sdk";
499
+
500
+ // Type a wrapper prop:
501
+ type SelectArgs = { preferredProvider?: PaymentProvider };
502
+
503
+ // Or read the value at runtime:
504
+ const all = Object.values(PaymentProvider); // ['PAYU', 'PRZELEWY24', ...]
505
+ ```
506
+
507
+ **Migration checklist**: optional — if you used a deep import to reach these enums, switch to the package root import.
508
+
509
+ `@doswiftly/storefront-operations` carries a version-sync bump only — no operation or schema change.
510
+
511
+ - 8c0d8fa: Expose `operations.json` through the package `exports` map.
512
+
513
+ **Why**: Documentation generators, MDX components, and other build-time tools need structural access to operation metadata (name, kind, section, description, variables, fragmentRefs, body) without parsing raw `.graphql` files. The `operations.json` file was already published in the npm tarball; this change makes it consumable via a typed subpath import.
514
+
515
+ **Additive (backward-compatible)**:
516
+
517
+ ```ts
518
+ // New: structural import of all operations
519
+ import operations from "@doswiftly/storefront-operations/operations.json";
520
+
521
+ console.log(operations.queries.length); // 52
522
+ console.log(operations.mutations.length); // 41
523
+
524
+ // Existing imports unchanged:
525
+ import schema from "@doswiftly/storefront-operations/schema.graphql";
526
+ ```
527
+
528
+ **Usage example** — build-time docs generation:
529
+
530
+ ```ts
531
+ import operations from "@doswiftly/storefront-operations/operations.json";
532
+
533
+ const cartOps = operations.mutations.filter(
534
+ (op) => op.section === "Cart Mutations",
535
+ );
536
+
537
+ for (const op of cartOps) {
538
+ console.log(`${op.name}: ${op.description}`);
539
+ console.log(
540
+ ` Variables: ${op.variables.map((v) => `${v.name}: ${v.type}`).join(", ")}`,
541
+ );
542
+ }
543
+ ```
544
+
545
+ **Migration checklist**: none — additive change, existing consumers unaffected.
546
+
547
+ ## 17.0.0
548
+
549
+ ### Major Changes
550
+
551
+ - d3f0f04: `cartSelectPaymentMethod` mutation is now method-centric.
552
+
553
+ The buyer picks a payment **category** (`BLIK`, `CARD`, `BANK_TRANSFER`, ...) and the backend resolves the preferred gateway from the merchant's `MerchantPaymentConfig` at `paymentCreate` time. Previously the storefront had to look up a per-provider tile UUID via `availablePaymentMethods` and pass it as `paymentMethodId`.
554
+
555
+ **Breaking** — `CartSelectPaymentMethodInput` shape changed:
556
+
557
+ ```graphql
558
+ # Before
559
+ input CartSelectPaymentMethodInput {
560
+ cartId: ID!
561
+ paymentMethodId: ID! # UUID of a per-provider tile
562
+ }
563
+
564
+ # After
565
+ input CartSelectPaymentMethodInput {
566
+ cartId: ID!
567
+ methodType: PaymentMethodType! # BLIK, CARD, BANK_TRANSFER, ...
568
+ preferredProviderId: String # optional override (provider code)
569
+ }
570
+ ```
571
+
572
+ Migration:
573
+
574
+ ```ts
575
+ // Before
576
+ await sdk.cartSelectPaymentMethod({ input: { cartId, paymentMethodId } });
577
+
578
+ // After
579
+ await sdk.cartSelectPaymentMethod({ input: { cartId, methodType: "BLIK" } });
580
+ // Or with explicit gateway override:
581
+ await sdk.cartSelectPaymentMethod({
582
+ input: { cartId, methodType: "BLIK", preferredProviderId: "payu" },
583
+ });
584
+ ```
585
+
586
+ The `availablePaymentMethods` query now returns deduplicated rows per `PaymentMethodType` (one row per BLIK, CARD, ...) with `providersAvailable` (gateway codes sorted by merchant priority) and `preferredProvider` (head of the list). The legacy `group: BY_PROVIDER` argument was removed — there is one canonical shape.
587
+
588
+ **Why** — A merchant configuring PayU + Przelewy24 would previously see "PayU BLIK" and "P24 BLIK" as two separate tiles on the storefront. After this change the storefront shows one BLIK tile; the merchant chooses which gateway handles BLIK via priority settings, and the buyer never sees the internal routing.
589
+
590
+ **Live capability sync (opt-in)** — gateway adapters may expose an optional `getAvailablePaymentMethods(credentials, { currency, amount, country })` capability returning the gateway-reported active methods (min/max amount, brand image, enabled flag). When supported, the platform caches the response per shop/currency and uses it to refine the storefront picker. Gateways without the capability fall back to a static capability matrix. Gateway HTTP failures are soft-failed — the buyer always sees the merchant's configured methods even when the upstream is temporarily unreachable.
591
+
592
+ - 7931668: Payment instrument preselection — direct deep-link to a specific instrument screen on the hosted gateway page (BLIK code, branded bank, wallet, card brand) instead of the gateway default landing.
593
+
594
+ **Why**: shoppers picking BLIK from the storefront UI now skip the gateway method picker entirely — the redirect lands directly on the BLIK code entry. Same flow for picking mBank, ING, Apple Pay, etc. Industry shorthand calls this "deep-link checkout" — 5-15% conversion uplift on single-instrument funnels.
595
+
596
+ **Breaking schema changes**:
597
+ 1. `CartSelectPaymentMethodInput.preferredProviderId` → `preferredProviderCode`. Rename only — same semantics (override the merchant's preferred provider). Aligns with the `*Code` naming convention (`*Id` reserved for UUIDs, `*Code` for lowercase string identifiers like `payu` / `przelewy24`).
598
+
599
+ ```graphql
600
+ # Before
601
+ input CartSelectPaymentMethodInput {
602
+ cartId: ID!
603
+ methodType: PaymentMethodType!
604
+ preferredProviderId: String # ← removed
605
+ }
606
+
607
+ # After
608
+ input CartSelectPaymentMethodInput {
609
+ cartId: ID!
610
+ methodType: PaymentMethodType!
611
+ preferredProviderCode: String # ← renamed
612
+ preferredInstrumentCode: String # ← new (additive)
613
+ }
614
+ ```
615
+
616
+ 2. `PaymentMethod.provider` / `PaymentMethod.providersAvailable` / `PaymentMethod.preferredProvider` / `Order.paymentMethod` / `Payment.provider` are now the typed `ProviderCode` enum (UPPERCASE: `PAYU`, `PRZELEWY24`, `STRIPE`, `CASH_ON_DELIVERY`, `BANK_TRANSFER`, `MANUAL_PAYMENT`, `GIFT_CARD`, `TEST_GATEWAY`) instead of `String`. Replace any `if (provider === 'payu')` storefront branches with the imported enum.
617
+
618
+ ```ts
619
+ // Before
620
+ if (paymentMethod.provider === "payu") {
621
+ /* ... */
622
+ }
623
+
624
+ // After
625
+ import { ProviderCode } from "@doswiftly/storefront-sdk";
626
+ if (paymentMethod.provider === ProviderCode.PAYU) {
627
+ /* ... */
628
+ }
629
+ ```
630
+
631
+ 3. `PaymentMethod.amountLimits` removed from the SDK fragment. The field still exists on the schema for backward-compat, but the SDK no longer selects it — most storefronts never used it and the data is provider-specific (PayU/P24 report limits per method, not per checkout). Re-add it to your local fragment if you need it.
632
+
633
+ **Additive (backward-compatible)**:
634
+ - `PaymentMethod.instruments: [PaymentMethodInstrument!]` — concrete instruments exposed by the gateway (BLIK code, branded banks, wallets, card brands). Each instrument carries `providerCode`, `instrumentCode`, `displayName`, `brandImageUrl`, `displayHint` (semantic UX hint: `PIN_ENTRY` / `WALLET_TAP` / `BANK_LIST` / `CARD_FORM`), `enabled`. `null` when the gateway doesn't expose granular data; empty array when all instruments are disabled.
635
+ - `cartSelectPaymentMethod(input: { preferredInstrumentCode })` — pass the chosen instrument's `instrumentCode` to persist it on the cart. Eager validation cross-checks against the gateway's live capabilities — invalid codes return `userErrors[].code = 'CART_INVALID_INSTRUMENT_CODE'`.
636
+ - `Cart.selectedPaymentInstrumentCode: String` — round-trips the persisted choice so the storefront can re-render the selected instrument on cart refresh.
637
+ - `Order.paymentInstrumentCode: String` — propagated from cart at `cartComplete`; the gateway adapter uses it to build the deep-link payload at `paymentCreate`.
638
+
639
+ **Usage example** — instrument-level deep-link checkout:
640
+
641
+ ```ts
642
+ import { useCartManager, ProviderCode } from "@doswiftly/storefront-sdk";
643
+
644
+ const { selectPaymentMethod, complete, createPayment } = useCartManager();
645
+
646
+ // 1. Render the instrument tiles from the gateway-reported list
647
+ const method = availablePaymentMethods.methods.find((m) => m.type === "BLIK");
648
+ const instrument = method?.instruments?.find(
649
+ (i) => i.instrumentCode === "blik",
650
+ );
651
+
652
+ // 2. Persist the buyer's choice on the cart
653
+ await selectPaymentMethod({
654
+ methodType: "BLIK",
655
+ preferredProviderCode: "payu",
656
+ preferredInstrumentCode: instrument.instrumentCode,
657
+ });
658
+
659
+ // 3. Complete the cart — instrument propagates to the Order
660
+ const { order } = await complete();
661
+
662
+ // 4. Initiate payment — gateway redirects directly to the BLIK code screen
663
+ const session = await createPayment(order.id);
664
+ window.location.href = session.redirectUrl;
665
+ ```
666
+
667
+ **Error handling**:
668
+ - `userErrors[].code === 'CART_INVALID_INSTRUMENT_CODE'` — instrument code is not available with the chosen provider (gateway disabled it, merchant config changed, typo). Refresh `availablePaymentMethods` and re-prompt the buyer.
669
+ - `paymentCreate` failed gateway initiation with the preselected instrument? The order's `paymentInstrumentCode` is automatically cleared (best-effort) — the next `paymentCreate` call falls back to the gateway default landing page. Storefront just retries.
670
+
671
+ **Rate limits**:
672
+ - `availablePaymentMethods` Query: 60 requests/min per IP+shop. Live capability lookups (OAuth + gateway list call) are expensive — hitting the limit returns `extensions.code: 'THROTTLED'` with `retryAfter`. Don't poll; cache the result for the duration of the checkout step.
673
+
674
+ **Gateway API version pinning**:
675
+ - PayU API pinned to `v2_1`, Przelewy24 API pinned to `v1`. When the upstream gateway ships a major version migration, the SDK / operations packages will bump major together with the constant update.
676
+
677
+ **Migration checklist for existing storefronts**:
678
+ - [ ] Rename `preferredProviderId` → `preferredProviderCode` everywhere in storefront mutations.
679
+ - [ ] Replace string comparisons (`provider === 'payu'`) with `ProviderCode` enum.
680
+ - [ ] Re-add `amountLimits` to your local PaymentMethod fragment if you used it.
681
+ - [ ] (Optional) Render the new `instruments` array as tiles to enable instrument-level deep-link checkout — 1-2 extra hours of UI work, measurable conversion impact on single-instrument flows.
682
+
683
+ ### Minor Changes
684
+
685
+ - fd82b62: `PaymentMethod` type gains availability + amount limit fields.
686
+
687
+ The storefront `PaymentMethod` returned by `availablePaymentMethods` (and `Cart.availablePaymentMethods` / `Cart.selectedPaymentMethod`) now carries three additional fields:
688
+ - **`available: Boolean!`** — `false` when the resolving gateway is temporarily unavailable (incident, maintenance) or reported the method as disabled. Storefront should gray-out the tile instead of hiding it — gives merchants observability into routing failures.
689
+ - **`unavailableReason: PaymentMethodUnavailableReason`** — diagnostic enum (`GATEWAY_DOWN`, `GATEWAY_DISABLED`, `NO_INSTRUMENTS`, `CREDENTIALS_INVALID`). Null when `available` is true. Use for context-aware copy ("Provider is temporarily down" vs "Method not configured").
690
+ - **`amountLimits: PaymentMethodAmountLimits`** — gateway-reported min/max transaction amount in the resolving currency (e.g. BLIK 1-20000 PLN). Filter the picker against cart total. Null when the gateway does not report limits.
691
+
692
+ ```graphql
693
+ fragment PaymentMethod on PaymentMethod {
694
+ # existing fields (id, name, provider, type, icon, ...)
695
+ providersAvailable
696
+ preferredProvider
697
+ available
698
+ unavailableReason
699
+ amountLimits {
700
+ min
701
+ max
702
+ currency
703
+ }
704
+ }
705
+ ```
706
+
707
+ **Currency** is automatically resolved from the storefront context (cascade: `@inContext(currency:)` directive → `X-Preferred-Currency` header → cookie → `Accept-Language` → shop default). Override per query via the `@inContext(currency: "EUR")` directive — no new schema argument.
708
+
709
+ **Migration example** for storefront pickers:
710
+
711
+ ```ts
712
+ const { methods } = await sdk.cart.availablePaymentMethods();
713
+ return methods.map((method) => (
714
+ <PaymentTile
715
+ key={method.type}
716
+ type={method.type}
717
+ disabled={!method.available}
718
+ tooltip={method.unavailableReason && reasonCopy[method.unavailableReason]}
719
+ badge={method.amountLimits && cart.total > method.amountLimits.max ? 'Cart too large' : null}
720
+ />
721
+ ));
722
+ ```
723
+
724
+ Backend additionally sanitizes provider error messages (strips OAuth tokens, URLs, JSON secret keys, stack traces) before returning them in admin diagnostic responses — internal logs keep the full error for diagnostics.
725
+
726
+ - c878d14: Payment instrument preselection — storefront UX completion (distinct error code + warnings array + explicit deselect mutation).
727
+
728
+ **Why**: storefront dispatching dla instrument failure stał się context-aware. Dziś generic `PAYMENT_FAILED` post-auto-clear nie pozwalał odróżnić "instrument cleared, retry default landing OK" od "real gateway outage, retry kolejnym attempt". Plus brak explicit deselect — accordion "wróć do wyboru metody" UI wymagał hack typu re-select method.
729
+
730
+ **Additive (backward-compatible)**:
731
+ 1. `PaymentErrorCode.INSTRUMENT_PRESELECTION_FAILED` — new enum value. Wystawiany po auto-clear `Order.paymentInstrumentCode` post-`InvalidPaymentInstrumentError`. Distinct od `PAYMENT_FAILED` (generic gateway failure fallback dla credentials / network / 5xx / circuit breaker).
732
+ 2. `PaymentCreatePayload.warnings: [PaymentWarning!]!` — new field, default empty array. Emitted post-auto-clear z entry `{ code: INSTRUMENT_CLEARED_FOR_RETRY, message, retryHint: null }`. Storefront dispatches retry hint differently (accordion reset / progress bar / itp.).
733
+ 3. `cartClearPaymentSelection(input: { cartId: ID! })` — new mutation. Atomic NULL across all payment selection fields. Idempotent. The cart must be `ACTIVE` — `CONVERTED` carts reject with `userErrors[0].code = 'ALREADY_COMPLETED'`. Rate limit 30/min.
734
+
735
+ **Storefront usage** — handle instrument failure z context-aware retry:
736
+
737
+ ```ts
738
+ import {
739
+ useCartManager,
740
+ type CartMutationOutcome,
741
+ } from "@doswiftly/storefront-sdk";
742
+
743
+ const { selectPaymentMethod, clearPaymentSelection, createPayment } =
744
+ useCartManager();
745
+
746
+ // 1. Klient wybiera BLIK preselect
747
+ await selectPaymentMethod({
748
+ methodType: "BLIK",
749
+ preferredProviderCode: "payu",
750
+ preferredInstrumentCode: "blik",
751
+ });
752
+
753
+ // 2. Try paymentCreate — może rzucić instrument-specific error
754
+ try {
755
+ const session = await createPayment(orderId);
756
+ window.location.href = session.redirectUrl;
757
+ } catch (err) {
758
+ if (err.userErrors?.[0]?.code === "INSTRUMENT_PRESELECTION_FAILED") {
759
+ // Backend auto-cleared instrument — show retry button
760
+ // Warning message available via err.warnings[0].message
761
+ showRetryUI({
762
+ title: "Instrument płatności niedostępny",
763
+ message: err.warnings?.[0]?.message ?? err.message,
764
+ onRetry: () => createPayment(orderId), // next attempt = default landing
765
+ });
766
+ } else if (err.userErrors?.[0]?.code === "PAYMENT_FAILED") {
767
+ // Generic gateway outage — retry z tym samym instrumentem
768
+ showOutageUI({
769
+ message: err.message,
770
+ onRetry: () => createPayment(orderId),
771
+ });
772
+ }
773
+ }
774
+
775
+ // 3. Accordion "wróć do wyboru metody" — explicit deselect
776
+ await clearPaymentSelection({ cartId });
777
+ // Cart.selectedPaymentMethod === null, Cart.selectedPaymentInstrumentCode === null
778
+ ```
779
+
780
+ **Migration guide for existing storefronts**:
781
+ - [ ] Add `warnings[]` to your `paymentCreate` selection set (if you use a custom GraphQL document instead of the SDK helpers).
782
+ - [ ] Branch UI dispatch on `userErrors[0].code === 'INSTRUMENT_PRESELECTION_FAILED'` distinct from `PAYMENT_FAILED`.
783
+ - [ ] (Optional) Replace any accordion-reset hack with `cartClearPaymentSelection({ cartId })` — clearer API, idempotent, single round-trip.
784
+
785
+ **Standards note**: splitting `userErrors[]` (blocking) and `warnings[]` (non-blocking) is a common e-commerce convention. The `INSTRUMENT_PRESELECTION_FAILED` semantics match retry-hint patterns used by major payment gateway SDKs (Stripe `payment_method_unavailable`, etc.).
786
+
787
+ - c553c80: Payment instrument preselection — cart re-validation stale signal + headless instrument components + browser data helper.
788
+
789
+ **Why**: previously the storefront kept showing an obsolete instrument selection until the buyer clicked "pay" — only then did the gateway reject it. The signal is now **proactive**: every cart query re-validates the selection against live gateway capabilities and emits a warning when the method or instrument disappeared. Plus a set of pre-built headless React components so the instrument picker no longer requires manual `displayHint` dispatch.
790
+
791
+ **Additive (backward-compatible)**:
792
+ 1. `Cart.warnings: [CartWarning!]!` — new field, default empty array. Computed at query time. Currently emitted: `PAYMENT_SELECTION_STALE` (code) when `selectedPaymentMethod` or `selectedPaymentInstrumentCode` no longer matches live gateway capabilities. Read-only signal — backend persistence is preserved (clear via `cartClearPaymentSelection` or a fresh `cartSelectPaymentMethod`).
793
+ 2. `Cart.selectedPaymentMethod` re-validation — when the method disappeared from live caps the field returns `null` plus a warning with `target: 'selectedPaymentMethod'`.
794
+ 3. `Cart.selectedPaymentInstrumentCode` re-validation — when only the instrument disappeared (method preserved), the field returns `null` plus a warning with `target: 'selectedPaymentInstrumentCode'` (method-level signal stays intact). Backend persistence is preserved — a re-select gets a fresh state.
795
+ 4. `CartWarningCode.PAYMENT_SELECTION_STALE` — new enum value in the existing `CartWarning` envelope.
796
+ 5. Graceful degradation — when the live capability check fails (gateway timeout / network outage), `Cart` returns the existing selection without a warning (UX continuity over freshness; `cartComplete` time-of-payment validation is the final safety net).
797
+ 6. `<PaymentInstrumentTile>` — new pre-built headless component. Single instrument button with full ARIA contract (`role="radio"`, `aria-checked`, `aria-label`, `data-instrument-code`, `data-display-hint`, `data-selected`). Zero opinionated styling — class props per part (button, icon, label). `displayHint` is emitted as a `data-display-hint` attribute so you can style via CSS attribute selectors.
798
+ 7. `<PaymentInstrumentSection>` — new pre-built headless component. Radio-group container with keyboard navigation (ArrowUp/Down/Left/Right wrap, Home/End jump). Renders one `<PaymentInstrumentTile>` per instrument in the order received from `availablePaymentMethods` (no client-side resort — the backend ordering is the source of truth).
799
+ 8. `getBrowserDataForPayment()` — new helper. Collects PSD2/3DS2 browser context (`userAgent`, `language`, screen dimensions, color depth, timezone offset, IANA timezone, `javaEnabled` fallback). Browser-only — throws `BrowserDataNotAvailableError` in SSR. Forward-looking utility for future 3DS challenge flows.
800
+
801
+ **Storefront usage** — listen to the stale signal in your cart query:
802
+
803
+ ```ts
804
+ const { cart } = useCart(cartId);
805
+
806
+ const staleWarning = cart?.warnings?.find(
807
+ (w) => w.code === "PAYMENT_SELECTION_STALE",
808
+ );
809
+
810
+ if (staleWarning) {
811
+ // The backend signals that the buyer picked a method which is no longer
812
+ // available at the gateway — show a "pick another method" dialog.
813
+ // `target` distinguishes method vs instrument level:
814
+ if (staleWarning.target === "selectedPaymentMethod") {
815
+ // cart.selectedPaymentMethod === null — full re-select required
816
+ } else if (staleWarning.target === "selectedPaymentInstrumentCode") {
817
+ // cart.selectedPaymentMethod is still set, only the instrument cleared
818
+ }
819
+ // Backend persistence is preserved — `cartClearPaymentSelection` or
820
+ // a fresh `cartSelectPaymentMethod` is the consumer's responsibility.
821
+ }
822
+ ```
823
+
824
+ **Storefront usage** — instrument picker with pre-built components:
825
+
826
+ ```tsx
827
+ import { PaymentInstrumentSection } from "@doswiftly/storefront-sdk/react";
828
+ import { useState } from "react";
829
+
830
+ function CheckoutPaymentStep({ method }: { method: PaymentMethod }) {
831
+ const [instrumentCode, setInstrumentCode] = useState<string | undefined>(
832
+ undefined,
833
+ );
834
+
835
+ return (
836
+ <PaymentInstrumentSection
837
+ method={method}
838
+ selectedInstrumentCode={instrumentCode}
839
+ onSelectInstrument={(code) => {
840
+ setInstrumentCode(code);
841
+ cart.selectPaymentMethod({
842
+ methodType: method.type,
843
+ preferredProviderCode: method.preferredProvider,
844
+ preferredInstrumentCode: code,
845
+ });
846
+ }}
847
+ sectionClassName="grid grid-cols-2 gap-2"
848
+ tileClassName="rounded border p-3 hover:bg-gray-50 data-[selected=true]:border-blue-500"
849
+ labelClassName="font-semibold"
850
+ ariaLabel="Pick a payment instrument"
851
+ />
852
+ );
853
+ }
854
+ ```
855
+
856
+ **Storefront usage** — browser data for future 3DS flows:
857
+
858
+ ```ts
859
+ import { getBrowserDataForPayment, BrowserDataNotAvailableError } from '@doswiftly/storefront-sdk/react';
860
+
861
+ function handleCheckoutSubmit() {
862
+ try {
863
+ const browserData = getBrowserDataForPayment();
864
+ // Pass to paymentCreate input when the gateway requires a 3DS challenge
865
+ await cart.createPayment({ ..., browserData });
866
+ } catch (err) {
867
+ if (err instanceof BrowserDataNotAvailableError) {
868
+ // SSR / no DOM — skip browser data; the gateway uses its default flow
869
+ }
870
+ throw err;
871
+ }
872
+ }
873
+ ```
874
+
875
+ **Migration checklist for existing storefronts**:
876
+ - [ ] Add `warnings { message code target }` to your cart query selection set (if you use a custom GraphQL document instead of the SDK fragments — SDK fragments are updated automatically).
877
+ - [ ] Branch UI on `cart.warnings[].code === 'PAYMENT_SELECTION_STALE'` to show a re-prompt dialog before checkout submit.
878
+ - [ ] (Optional) Replace a custom instrument picker with `<PaymentInstrumentSection>` — saves boilerplate, ships full ARIA + keyboard nav out of the box.
879
+ - [ ] (Forward-looking) Adopt `getBrowserDataForPayment()` in checkout submit handlers when 3DS flows become relevant (currently optional — the gateway accepts an undefined `browserData`).
880
+
881
+ **Standards reference**: `PaymentInstrumentSection` follows the WAI-ARIA radiogroup pattern (https://www.w3.org/WAI/ARIA/apg/patterns/radio/). The browser data helper shape matches the EMVCo 3DS2 BrowserData specification.
882
+
3
883
  ## 16.1.0
4
884
 
5
885
  ### Minor Changes