@doswiftly/storefront-sdk 21.0.0 → 21.0.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +776 -529
  3. package/dist/core/auth/handlers.d.ts +10 -9
  4. package/dist/core/auth/handlers.d.ts.map +1 -1
  5. package/dist/core/auth/handlers.js +10 -9
  6. package/dist/core/auth/session-events.d.ts +2 -2
  7. package/dist/core/auth/session-events.js +2 -2
  8. package/dist/core/cart/cart-client.d.ts +23 -24
  9. package/dist/core/cart/cart-client.d.ts.map +1 -1
  10. package/dist/core/cart/cart-client.js +24 -25
  11. package/dist/core/generated/operation-types.d.ts +50 -50
  12. package/dist/core/generated/operation-types.d.ts.map +1 -1
  13. package/dist/core/middleware/session-retry.d.ts +5 -6
  14. package/dist/core/middleware/session-retry.d.ts.map +1 -1
  15. package/dist/core/middleware/session-retry.js +7 -8
  16. package/dist/core/operations/auth.d.ts.map +1 -1
  17. package/dist/core/operations/auth.js +4 -0
  18. package/dist/core/operations/cart.d.ts +11 -10
  19. package/dist/core/operations/cart.d.ts.map +1 -1
  20. package/dist/core/operations/cart.js +14 -11
  21. package/dist/react/components/PaymentInstrumentSection.d.ts +24 -24
  22. package/dist/react/components/PaymentInstrumentSection.d.ts.map +1 -1
  23. package/dist/react/components/PaymentInstrumentSection.js +15 -15
  24. package/dist/react/components/PaymentInstrumentTile.d.ts +19 -20
  25. package/dist/react/components/PaymentInstrumentTile.d.ts.map +1 -1
  26. package/dist/react/components/PaymentInstrumentTile.js +15 -16
  27. package/dist/react/helpers/browser-data.d.ts +30 -33
  28. package/dist/react/helpers/browser-data.d.ts.map +1 -1
  29. package/dist/react/helpers/browser-data.js +26 -29
  30. package/dist/react/hooks/use-cart-manager.d.ts +1 -1
  31. package/dist/react/hooks/use-cart-manager.js +1 -1
  32. package/dist/react/hooks/use-cart.d.ts +2 -2
  33. package/dist/react/hooks/use-cart.js +3 -3
  34. package/dist/react/hooks/use-session-expired.d.ts +6 -5
  35. package/dist/react/hooks/use-session-expired.d.ts.map +1 -1
  36. package/dist/react/hooks/use-session-expired.js +6 -5
  37. package/dist/react/stores/auth.store.d.ts.map +1 -1
  38. package/dist/react/stores/auth.store.js +13 -10
  39. package/dist/react/stores/cart.store.d.ts +1 -1
  40. package/dist/react/stores/cart.store.js +1 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,17 @@ Layered runtime SDK for DoSwiftly Commerce storefronts. Framework-agnostic core
6
6
 
7
7
  ```
8
8
  @doswiftly/storefront-sdk
9
- ├── core (.) — Framework-agnostic: transport, middleware, CartClient, AuthClient,
10
- cache, format utilities, image types, sanitizeHtml,
11
- normalizeConnection, auth cookie config/handlers/token client, route matching
12
- ├── react (./react) — React adapter: providers, Zustand stores (Context-based), hooks,
13
- useHydrated, useDebouncedValue, createStoreContext
14
- ├── react/server Server-side client factory
9
+ ├── core (.) — Framework-agnostic: transport + middleware pipeline,
10
+ CartClient / AuthClient, cart recovery runner,
11
+ cart capability cookie (id + secret), auth route helpers,
12
+ │ bot-protection managers, errors, format utilities,
13
+ sanitizeHtml, normalizeConnection, schema enums
14
+ ├── react (./react) React adapter: StorefrontProvider, CartManagerProvider,
15
+ │ Zustand stores (Context-based), auth/cart/session hooks,
16
+ │ pre-built headless components
17
+ ├── react/server — Server-side: client factory, SDK-BFF auth route
18
+ │ (createStorefrontAuthRoute), getInitialAuth,
19
+ │ first-party cookie readers, server cart-secret middleware
15
20
  └── cache (./cache) — Cache strategy functions
16
21
  ```
17
22
 
@@ -38,383 +43,569 @@ Scaffolded storefronts (`doswiftly init`) ship a `graphqlConfig` helper (`lib/gr
38
43
 
39
44
  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
45
 
46
+ 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.
47
+
48
+ ## Quick start — Next.js App Router
49
+
50
+ Four files give you a production-grade storefront runtime: first-party auth cookies with automatic session refresh, a shared cart with stale-cart auto-recovery, and a typed GraphQL client with the full middleware pipeline.
51
+
52
+ **1. `app/api/auth/[action]/route.ts`** — one file mounts the whole auth surface
53
+ (`POST /api/auth/login | refresh | logout`, `GET /api/auth/whoami`). The handlers
54
+ run on the storefront's own domain, call the backend server-to-server, and own the
55
+ first-party httpOnly cookies — the refresh token never reaches browser JavaScript:
56
+
41
57
  ```ts
42
- // app/layout.tsx — Next.js App Router, template-generated wiring
43
- import { graphqlConfig } from '@/lib/graphql/config';
58
+ import {
59
+ createStorefrontAuthRoute,
60
+ trustedForwardedHostValidator,
61
+ } from '@doswiftly/storefront-sdk/react/server';
44
62
 
45
- <StorefrontProvider
46
- config={{ apiUrl: graphqlConfig.apiUrl, shopSlug: graphqlConfig.shopSlug }}
47
- shopData={shopData}
48
- >
49
- {children}
50
- </StorefrontProvider>
63
+ export const { GET, POST } = createStorefrontAuthRoute({
64
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
65
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
66
+ // Pass when the storefront runs behind a reverse proxy that rewrites Host
67
+ // (DoSwiftly hosting, Vercel). Omit for bare deployments / local dev.
68
+ isTrustedOrigin: trustedForwardedHostValidator,
69
+ });
51
70
  ```
52
71
 
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.
72
+ **2. `app/layout.tsx`** seed the first render from the first-party cookies via
73
+ `getInitialAuth()` (no signed-out flash, no whoami round-trip) and wrap the tree
74
+ in `StorefrontProvider`:
75
+
76
+ ```tsx
77
+ import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
78
+ import { getStorefrontClient, getInitialAuth } from '@doswiftly/storefront-sdk/react/server';
79
+
80
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
81
+ const serverClient = getStorefrontClient({
82
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
83
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
84
+ });
85
+
86
+ // `SHOP_CONFIG_QUERY` is your own operation — generated by graphql-codegen from
87
+ // `@doswiftly/storefront-operations/schema.graphql`. It must request the fields
88
+ // of the `ShopConfig` shape: `currencyCode`, `supportedCurrencies`,
89
+ // `defaultLanguage`, `supportedLanguages`, `botProtection`.
90
+ const [{ shop }, initialAuth] = await Promise.all([
91
+ serverClient.query(SHOP_CONFIG_QUERY),
92
+ getInitialAuth(),
93
+ ]);
94
+
95
+ return (
96
+ <html lang="pl">
97
+ <body>
98
+ <StorefrontProvider
99
+ config={{
100
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
101
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
102
+ }}
103
+ shopData={shop}
104
+ initialIsAuthenticated={initialAuth.isAuthenticated}
105
+ initialAccessToken={initialAuth.accessToken}
106
+ initialExpiresAt={initialAuth.expiresAt}
107
+ >
108
+ {children}
109
+ </StorefrontProvider>
110
+ </body>
111
+ </html>
112
+ );
113
+ }
114
+ ```
115
+
116
+ Session refresh is **automatic** — the provider defaults to `autoRefresh` in the
117
+ browser: a scheduler renews the access token shortly before it expires, and a 401
118
+ on a read query triggers a single deduped refresh + replay. Pass
119
+ `autoRefresh={false}` to drive refreshing yourself.
120
+
121
+ **3. Cart — wrap the shopping subtree in `CartManagerProvider`** (one shared cart
122
+ manager: one loading state, one recovery queue) and read it with
123
+ `useCartManagerContext()`:
124
+
125
+ ```tsx
126
+ 'use client';
127
+ import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
128
+ import { toast } from 'sonner';
129
+
130
+ export function ShopProviders({ children }: { children: React.ReactNode }) {
131
+ return (
132
+ <CartManagerProvider
133
+ onMutationError={(operation, error) => toast.error(error.message)}
134
+ >
135
+ {children}
136
+ </CartManagerProvider>
137
+ );
138
+ }
139
+
140
+ export function AddToCart({ variantId }: { variantId: string }) {
141
+ const { addItem, status } = useCartManagerContext();
142
+ return (
143
+ <button
144
+ onClick={() => addItem([{ variantId, quantity: 1 }])}
145
+ disabled={status.type === 'loading'}
146
+ >
147
+ Add to cart
148
+ </button>
149
+ );
150
+ }
151
+ ```
152
+
153
+ The cart cookie, the cart access secret, creation on first add, and stale-cart
154
+ recovery are all handled for you — see [Cart](#cart).
54
155
 
55
- ## Quick Start
156
+ **4. Global session + cart expiry handling** — mount once near the root:
157
+
158
+ ```tsx
159
+ 'use client';
160
+ import { useEffect } from 'react';
161
+ import { useSessionExpired, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
162
+ import { useRouter } from 'next/navigation';
163
+ import { toast } from 'sonner';
164
+
165
+ export function GlobalGuards() {
166
+ const router = useRouter();
167
+ const { onExpired } = useCartManagerContext();
168
+
169
+ // Fired when the SDK can no longer keep the customer session alive.
170
+ useSessionExpired(() => router.replace('/auth/login?reason=session_expired'));
171
+
172
+ // Fired when a stale cart cannot be transparently recovered.
173
+ useEffect(
174
+ () => onExpired(() => toast.error('Your cart expired — please add the items again')),
175
+ [onExpired],
176
+ );
177
+ return null;
178
+ }
179
+ ```
56
180
 
57
- ### Core (framework-agnostic)
181
+ ## Quick start — Core (framework-agnostic)
58
182
 
59
183
  ```typescript
60
184
  import {
61
185
  createStorefrontClient,
62
- authMiddleware,
63
- currencyMiddleware,
186
+ cartSecretMiddleware,
64
187
  retryMiddleware,
65
188
  timeoutMiddleware,
66
189
  errorMiddleware,
67
190
  CartClient,
68
- AuthClient,
191
+ formatCartCookieValue,
69
192
  } from '@doswiftly/storefront-sdk';
70
193
 
194
+ let cartSecret: string | null = null;
195
+
71
196
  const client = createStorefrontClient({
72
197
  apiUrl: 'https://api.doswiftly.pl',
73
198
  shopSlug: 'my-shop',
74
199
  middleware: [
75
- authMiddleware(() => getToken()),
76
- currencyMiddleware(() => getCurrency()),
200
+ cartSecretMiddleware(() => cartSecret), // lazy getter — picks up rotation
77
201
  retryMiddleware({ maxRetries: 2 }),
78
202
  timeoutMiddleware({ timeout: 5000 }),
79
203
  errorMiddleware(), // ALWAYS LAST
80
204
  ],
81
205
  });
82
206
 
83
- // Query (deduplicated, cached)
84
- const data = await client.query(ProductQuery, { handle: 'foo' }, cacheLong());
207
+ const cartClient = new CartClient(client);
208
+
209
+ // `create` reveals a one-time cart access secret — store it immediately.
210
+ // Possession of the secret is what authorizes cart reads and writes.
211
+ const { cart, secret } = await cartClient.create();
212
+ cartSecret = secret;
213
+ if (secret) {
214
+ persistCookie('cart-id', formatCartCookieValue({ cartId: cart.id, cartSecret: secret }));
215
+ }
85
216
 
86
- // Mutation (never cached, never retried)
87
- const result = await client.mutate(CartCreateMutation, { input: {} });
217
+ const { cart: updated, warnings } = await cartClient.addItems(cart.id, [
218
+ { variantId: 'variant-123', quantity: 1 },
219
+ ]);
88
220
  ```
89
221
 
90
- ### React (Next.js)
222
+ Queries are deduplicated and cacheable; mutations are never cached and never
223
+ retried.
91
224
 
92
- ```tsx
93
- // app/layout.tsx
94
- import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
225
+ ## Export paths
95
226
 
96
- export default function Layout({ children }) {
97
- return (
98
- <StorefrontProvider
99
- config={{ apiUrl: process.env.NEXT_PUBLIC_API_URL!, shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG! }}
100
- shopData={shopData}
101
- >
102
- {children}
103
- </StorefrontProvider>
104
- );
105
- }
106
- ```
227
+ | Path | Description | Dependencies |
228
+ |------|-------------|-------------|
229
+ | `@doswiftly/storefront-sdk` | Core: transport, middleware, clients, recovery, errors, format, enums, cookie contracts | **0** |
230
+ | `@doswiftly/storefront-sdk/react` | Providers, hooks, stores, pre-built UI components | react, zustand |
231
+ | `@doswiftly/storefront-sdk/react/server` | Server client factory, SDK-BFF auth route, cookie readers | react (peer; `getInitialAuth` additionally requires Next.js) |
232
+ | `@doswiftly/storefront-sdk/cache` | Cache strategy functions | **0** |
233
+
234
+ ## Authentication
235
+
236
+ ### Session model
237
+
238
+ Auth runs through **BFF route handlers on the storefront's own domain**
239
+ (`createStorefrontAuthRoute`). The browser never talks to the backend directly
240
+ for auth — the route handlers do, server-to-server. First-party cookies work
241
+ identically on a platform subdomain, a custom domain, and off-platform hosting.
242
+
243
+ | Cookie | httpOnly | Path | Purpose |
244
+ |--------|----------|------|---------|
245
+ | `customerAccessToken` | yes | `/` | Access token — read server-side to seed SSR and the in-memory store |
246
+ | `customerRefreshToken` | yes | `/api/auth` | Refresh token — read exclusively server-side by the BFF route |
247
+ | `session-expiry` | no | `/` | Readable absolute expiry (ISO 8601) for the refresh scheduler |
248
+
249
+ Renewal is automatic (`<StorefrontProvider autoRefresh>` — default ON in the
250
+ browser):
251
+
252
+ - **Proactive** — a scheduler calls `POST {authBasePath}/refresh` shortly before
253
+ `expiresAt`; the route rotates the refresh cookie and returns a fresh access token.
254
+ - **Reactive** — a 401 on a read query triggers one deduped refresh and replays
255
+ the query. A 401 on a **mutation** never retries — it fires the
256
+ `session-expired` signal instead (replaying a mutation, e.g. a payment, would
257
+ be unsafe).
258
+
259
+ When the session cannot be kept alive, subscribe globally:
107
260
 
108
261
  ```tsx
109
- // Client Component
110
262
  'use client';
111
- import { useAuth, useCartManager, useAuthStore, useCurrencyStore, useAuthHydrated } from '@doswiftly/storefront-sdk/react';
263
+ import { useSessionExpired } from '@doswiftly/storefront-sdk/react';
112
264
 
113
- const { login, logout } = useAuth({ onSetToken, onClearToken });
114
- const { addItem, removeItem, lines, isLoading } = useCartManager();
115
- const { isAuthenticated, customer } = useAuthStore();
116
- const authHydrated = useAuthHydrated(); // true after persist rehydration
117
- const { currency, setCurrency } = useCurrencyStore();
265
+ useSessionExpired((event) => router.replace('/auth/login'));
118
266
  ```
119
267
 
120
- ## Export Paths
268
+ ### Sign-in form (BFF login)
121
269
 
122
- | Path | Description | Dependencies |
123
- |------|-------------|-------------|
124
- | `@doswiftly/storefront-sdk` | Core: transport, middleware, clients, errors, format, image types, sanitize, auth handlers, route matching | **0** |
125
- | `@doswiftly/storefront-sdk/react` | Providers, hooks, pre-built UI components, Zustand stores | react, zustand |
126
- | `@doswiftly/storefront-sdk/react/server` | Server-side client factory | react |
127
- | `@doswiftly/storefront-sdk/cache` | Cache strategy functions | **0** |
270
+ `POST {authBasePath}/login` is the only flow that sets the **full cookie set**
271
+ (access + refresh + expiry) on the storefront domain — required for automatic
272
+ session refresh. Post the credentials, then seed the client-side store with the
273
+ returned token:
128
274
 
129
- ## Next.js 16 App Router — first add-to-cart in 15 minutes
275
+ ```tsx
276
+ 'use client';
277
+ import { useAuthStore } from '@doswiftly/storefront-sdk/react';
278
+
279
+ export function LoginForm() {
280
+ const setAuth = useAuthStore((s) => s.setAuth);
281
+
282
+ async function onSubmit(email: string, password: string) {
283
+ const res = await fetch('/api/auth/login', {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify({ email, password }),
287
+ });
288
+ if (!res.ok) {
289
+ // Backend errors are passed through verbatim (already localized).
290
+ const body = await res.json().catch(() => null);
291
+ showError(body);
292
+ return;
293
+ }
294
+ const { accessToken, expiresAt, customer } = await res.json();
295
+ setAuth(customer ?? null, accessToken, expiresAt);
296
+ }
297
+ // ...
298
+ }
299
+ ```
300
+
301
+ After a successful sign-in, merge the guest cart into the customer's cart with
302
+ `cartClient.merge(guestCartId)` — see [Auth ↔ cart lifecycle](#auth--cart-lifecycle).
130
303
 
131
- End-to-end skeleton that gets you a working cart + auth flow without writing
132
- the middleware pipeline by hand. Copy the files below into a fresh `app/`
133
- directory and you're live.
304
+ ### GraphQL auth hooks `useLogin` / `useLogout` / `useAuth`
134
305
 
135
- **1. `app/layout.tsx`** wrap the tree in `StorefrontProvider`:
306
+ The focused hooks drive auth over the **GraphQL transport**
307
+ (`customerLogin` / `customerLogout` mutations) and keep the token in the
308
+ in-memory store. The backend sets/clears its own httpOnly cookie on these
309
+ mutations, on the **API domain** — fine for same-site setups and non-browser
310
+ clients. For first-party cookies on the storefront domain (and the refresh
311
+ cookie required by `autoRefresh`) prefer the BFF login above; the optional
312
+ `onSetToken` / `onClearToken` callbacks let you sync a cookie through your own
313
+ route during migration from older setups.
136
314
 
137
315
  ```tsx
138
- import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
139
- import { getStorefrontClient } from '@doswiftly/storefront-sdk/react/server';
140
- import { cookies } from 'next/headers';
141
- import { AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';
316
+ import { useLogin, useLogout } from '@doswiftly/storefront-sdk/react';
142
317
 
143
- export default async function RootLayout({ children }: { children: React.ReactNode }) {
144
- const serverClient = getStorefrontClient({
145
- apiUrl: process.env.NEXT_PUBLIC_API_URL!,
146
- shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
147
- });
148
- // `SHOP_BASICS_QUERY` is your own operation — generated by graphql-codegen
149
- // from `@doswiftly/storefront-operations/schema.graphql`. It should request
150
- // `shop.currency`, `shop.supportedCurrencies`, `shop.supportedLanguages`,
151
- // `shop.botProtection` — whatever StorefrontProvider needs.
152
- const { shop } = await serverClient.query(SHOP_BASICS_QUERY);
153
-
154
- // Read the raw JWT from the httpOnly auth cookie server-side and seed it into
155
- // the auth store. This eliminates the round-trip to `/api/auth/whoami` on the
156
- // first mount — `authMiddleware` adds `Authorization: Bearer ...` from the
157
- // very first request. Token stays in memory only (never written to
158
- // localStorage — `persist.partialize` excludes `accessToken`, XSS hardening).
159
- const cookieStore = await cookies();
160
- const initialAccessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value ?? null;
318
+ const { login, isLoggingIn, error } = useLogin();
319
+ const { logout, isLoggingOut } = useLogout();
161
320
 
162
- return (
163
- <html lang="pl">
164
- <body>
165
- <StorefrontProvider
166
- config={{
167
- apiUrl: process.env.NEXT_PUBLIC_API_URL!,
168
- shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
169
- }}
170
- shopData={shop}
171
- initialAccessToken={initialAccessToken}
172
- >
173
- {children}
174
- </StorefrontProvider>
175
- </body>
176
- </html>
177
- );
178
- }
321
+ const result = await login(email, password);
322
+ if (!result.success) showErrors(result.userErrors); // backend-translated messages
179
323
  ```
180
324
 
181
- > **Tip `initialIsAuthenticated` vs `initialAccessToken`:** if you only know
182
- > *whether* the user is signed in (cookie present, value not readable server-side),
183
- > pass `initialIsAuthenticated={Boolean(...)}`UI starts in the right gating
184
- > state, and the SDK fetches the token via `createWhoamiHandler` on first mount.
185
- > If you have the raw JWT server-side (cookie value, SSO redirect param,
186
- > dev-seed env var), prefer `initialAccessToken={token}` — no round-trip needed.
187
- > When `initialAccessToken` is truthy, `isAuthenticated` defaults to `true`
188
- > automatically; pass `initialIsAuthenticated={false}` to override.
325
+ `useLogout` additionally downgrades the active cart to guest before logging out
326
+ (clears the customer's contact details, addresses and saved-payment selection so
327
+ they do not linger on a shared device) — best-effort, never blocks sign-out.
189
328
 
190
- **2. `app/api/auth/set-token/route.ts`** BFF route that sets the httpOnly
191
- cookie. The SDK ships factories so each file is two lines:
329
+ `useAuth(options?)` is a convenience facade aggregating `useLogin`, `useLogout`
330
+ and `useRefreshToken`:
192
331
 
193
332
  ```ts
194
- import { createSetTokenHandler } from '@doswiftly/storefront-sdk';
195
- export const POST = createSetTokenHandler();
333
+ const {
334
+ login, logout, refreshToken,
335
+ isLoggingIn, isLoggingOut, isRefreshingToken, isLoading,
336
+ error,
337
+ } = useAuth();
196
338
  ```
197
339
 
198
- **3. `app/api/auth/clear-token/route.ts`** clears the cookie on logout:
340
+ Auth **state** (customer, flags) lives in the store, not in the hooks:
199
341
 
200
342
  ```ts
201
- import { createClearTokenHandler } from '@doswiftly/storefront-sdk';
202
- export const POST = createClearTokenHandler();
343
+ import { useAuthStore, useAuthHydrated } from '@doswiftly/storefront-sdk/react';
344
+
345
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
346
+ const customer = useAuthStore((s) => s.customer);
347
+ const authHydrated = useAuthHydrated(); // true after persist rehydration
203
348
  ```
204
349
 
205
- **4. `app/api/auth/whoami/route.ts`** hydrates customer state from the
206
- httpOnly cookie after a hard refresh (`accessToken` no longer persists in
207
- localStorage — XSS hardening):
350
+ ### AuthClient (no React)
208
351
 
209
- ```ts
210
- import { createWhoamiHandler } from '@doswiftly/storefront-sdk';
352
+ ```typescript
353
+ import { AuthClient } from '@doswiftly/storefront-sdk';
211
354
 
212
- export const GET = createWhoamiHandler({
213
- apiUrl: process.env.NEXT_PUBLIC_API_URL!,
214
- shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
215
- });
355
+ const authClient = new AuthClient(client, { authBasePath: '/api/auth' });
216
356
  ```
217
357
 
218
- **5. Sign-in form** uses the focused `useLogin` hook + wires the BFF
219
- callback:
358
+ | Method | Returns | Notes |
359
+ |---|---|---|
360
+ | `login(email, password)` | `Promise<AuthResult>` | GraphQL mutation; throws `StorefrontError` with backend-translated `userErrors` |
361
+ | `register(input)` | `Promise<AuthResult>` | `CustomerCreateInput`; result carries `customer` |
362
+ | `logout()` | `Promise<void>` | Never throws (token may already be expired) |
363
+ | `refreshSession()` | `Promise<SessionRefreshResult>` | Same-origin `POST {authBasePath}/refresh` (BFF) — works with an **expired** access token; throws `SESSION_EXPIRED` on failure |
364
+ | `getCustomer()` | `Promise<Customer \| null>` | `null` when unauthenticated |
365
+ | `getAddresses()` | `Promise<MailingAddress[] \| null>` | Saved address book incl. B2B fields (`taxId`, `vatNumber`) and `isDefault`; `null` when unauthenticated |
366
+ | `refreshToken()` | `Promise<AuthResult>` | **Deprecated** — GraphQL refresh requires a still-valid access token; use `refreshSession()` |
220
367
 
221
- ```tsx
222
- 'use client';
223
- import { useLogin } from '@doswiftly/storefront-sdk/react';
368
+ ### Low-level route handlers (escape hatch)
224
369
 
225
- export function LoginForm() {
226
- const { login, isLoggingIn, error } = useLogin({
227
- onSetToken: async (token) => {
228
- await fetch('/api/auth/set-token', {
229
- method: 'POST',
230
- headers: { 'Content-Type': 'application/json' },
231
- body: JSON.stringify({ token }),
232
- });
233
- },
234
- });
370
+ `createStorefrontAuthRoute` is the recommended mount. The standalone Web API
371
+ factories remain available for custom setups and migrations:
235
372
 
236
- return (
237
- <form onSubmit={async (e) => {
238
- e.preventDefault();
239
- const data = new FormData(e.currentTarget);
240
- const result = await login(String(data.get('email')), String(data.get('password')));
241
- if (result.success) location.href = '/account';
242
- }}>
243
- <input name="email" type="email" required />
244
- <input name="password" type="password" required />
245
- <button type="submit" disabled={isLoggingIn}>{isLoggingIn ? 'Signing in…' : 'Sign in'}</button>
246
- {error && <p role="alert">{error}</p>}
247
- </form>
248
- );
249
- }
373
+ ```ts
374
+ import {
375
+ createSetTokenHandler, // POST — sets the httpOnly access-token cookie
376
+ createClearTokenHandler, // POST clears it
377
+ createWhoamiHandler, // GET — hydrates { isAuthenticated, customer } from the cookie
378
+ } from '@doswiftly/storefront-sdk';
379
+
380
+ export const POST = createSetTokenHandler();
250
381
  ```
251
382
 
252
- **6. Product card with add-to-cart**pre-built components:
383
+ All handlers are pure Web API (`Request`/`Response`) they run in Next.js Route
384
+ Handlers, Cloudflare Workers, Deno, etc. Security baked in: strict origin
385
+ validation, Content-Type check, `SameSite=Lax`, httpOnly cookies.
253
386
 
254
- ```tsx
255
- 'use client';
256
- import { Image, PriceDisplay, AddToCartButton } from '@doswiftly/storefront-sdk/react';
387
+ ### Behind a reverse proxy
257
388
 
258
- export function ProductCard({ product }: { product: ProductCardFields }) {
259
- return (
260
- <article>
261
- <Image data={product.featuredImage} sizes="(max-width: 768px) 100vw, 33vw" />
262
- <h2>{product.title}</h2>
263
- <PriceDisplay
264
- price={product.price.amount * 100}
265
- compareAtPrice={product.compareAtPrice ? product.compareAtPrice.amount * 100 : undefined}
266
- currency={product.price.currencyCode}
267
- />
268
- <AddToCartButton variantId={product.firstVariant.id}>Add to cart</AddToCartButton>
269
- </article>
270
- );
271
- }
389
+ When a proxy rewrites/strips `Host` (DoSwiftly hosting, Vercel, NGINX), strict
390
+ `Origin host === Host` validation would 403 every auth call. Every handler (and
391
+ `createStorefrontAuthRoute`) accepts an `isTrustedOrigin` predicate:
392
+
393
+ ```ts
394
+ import {
395
+ trustedForwardedHostValidator, // trust X-Forwarded-Host (set by the proxy)
396
+ originAllowlistValidator, // static allowlist of origins
397
+ } from '@doswiftly/storefront-sdk';
398
+
399
+ createStorefrontAuthRoute({ apiUrl, shopSlug, isTrustedOrigin: trustedForwardedHostValidator });
400
+ // or
401
+ createSetTokenHandler({
402
+ isTrustedOrigin: originAllowlistValidator(['https://shop.example.com']),
403
+ });
272
404
  ```
273
405
 
274
- That's it. Cart recovery (stale-cart auto-replay) is automatic wire a single
275
- global `onExpired` toast subscriber once in `app/layout.tsx`:
406
+ Custom predicates get `({ origin, originHost, request })` and may be async. A
407
+ throwing predicate fails closed (falls back to strict matching).
276
408
 
277
- ```tsx
278
- 'use client';
279
- import { useEffect } from 'react';
280
- import { useCartManager } from '@doswiftly/storefront-sdk/react';
281
- import { toast } from 'sonner';
409
+ ### Auth token client (client-side helper)
282
410
 
283
- export function CartExpiredToast() {
284
- const { onExpired } = useCartManager();
285
- useEffect(() => onExpired((e) => {
286
- toast.error(e.reason === 'state-dependent'
287
- ? 'Twój koszyk wygasł, dodaj produkty ponownie'
288
- : 'Nie udało się odzyskać koszyka, spróbuj ponownie');
289
- }), [onExpired]);
290
- return null;
291
- }
411
+ ```typescript
412
+ import { createAuthTokenClient } from '@doswiftly/storefront-sdk';
413
+
414
+ const { setToken, clearToken } = createAuthTokenClient();
415
+ await setToken(accessToken); // POST /api/auth/set-token
416
+ await clearToken(); // POST /api/auth/clear-token
292
417
  ```
293
418
 
294
- ## Pre-built React components
419
+ ## Cart
295
420
 
296
- Headless, accessibility-aware, zero styling pass `className` to integrate
297
- with your CSS approach. Available from `@doswiftly/storefront-sdk/react`:
421
+ ### Capability modelcart id + secret
298
422
 
299
- | Component | Purpose |
300
- |-----------|---------|
301
- | `<Money amount currency>` | Locale-formatted price string from minor units |
302
- | `<Image data sizes priority>` | `<img>` with thumbhash blur placeholder + sane defaults |
303
- | `<CartCount count label>` | Aria-live cart item count |
304
- | `<AddToCartButton variantId quantity>` | Button wired to `useCartManager.addItem` |
305
- | `<PriceDisplay price compareAtPrice currency>` | Price + optional strikethrough sale price |
306
- | `<CartTotals subtotal tax shipping discount total currency>` | Cart financial breakdown `<dl>` |
423
+ Cart access is authorized by **possession of a secret**, not by the customer
424
+ session. The `cart-id` cookie stores a composite value `"<cartId>.<secret>"`
425
+ (30 days, SSR/edge-visible, not httpOnly the cart carries no payment data).
426
+ `CartClient.create()` and `recoveryRedeem()` reveal the secret **once**; the SDK
427
+ persists it into the cookie for you. Every request then carries the secret in
428
+ the `x-cart-secret` header via middleware:
429
+
430
+ - **Browser**: `StorefrontProvider` wires `cartSecretMiddleware` automatically
431
+ the secret is read lazily from the cookie on every request, so a rotated
432
+ secret is picked up without rebuilding the client.
433
+ - **Server (SSR/edge)**: prepend `serverCartSecretMiddleware(await readCartCredentials())`
434
+ to your server client — see [Server-side](#server-side-reactserver).
435
+ - **Custom runtimes**: `cartSecretMiddleware(() => secret)` + the
436
+ `parseCartCookieValue` / `formatCartCookieValue` helpers.
437
+
438
+ A cookie without the secret half (or a stale capability) makes the cart
439
+ unreachable — mutations reject with `CART_NOT_FOUND` and the standard recovery
440
+ flow recreates a fresh cart.
441
+
442
+ ### `useCartManager` — cookie-driven cart + checkout lifecycle
443
+
444
+ The primary React cart API. Owns the `cart-id` cookie, auto-creates the cart on
445
+ first add, persists the secret, and recovers from stale carts per operation:
307
446
 
308
447
  ```tsx
309
- import { Money, Image, CartCount, AddToCartButton, PriceDisplay, CartTotals } from '@doswiftly/storefront-sdk/react';
448
+ 'use client';
449
+ import { useCartManager } from '@doswiftly/storefront-sdk/react';
310
450
 
311
- <Money amount={9990} currency="PLN" /> {/* "99,90 zł" */}
312
- <PriceDisplay price={7990} compareAtPrice={9990} currency="PLN" />
313
- <CartCount count={3} label="items" />
451
+ const {
452
+ // Read
453
+ getCart, getCartId,
454
+ // Mutations (all return Promise<CartMutationOutcome> = { cart, warnings })
455
+ addItem, updateItem, removeItem,
456
+ updateBuyerIdentity, setShippingAddress, setBillingAddress,
457
+ updateDiscountCodes, updateNote, updateAttributes,
458
+ selectShippingMethod, selectPaymentMethod, clearPaymentSelection,
459
+ applyGiftCard, removeGiftCard, updateGiftCardRecipient,
460
+ // Completion
461
+ complete, // Promise<CartCompleteOutcome> = { order, warnings }
462
+ createPayment, // Promise<PaymentSession> — post-completion, works on orderId
463
+ // Lifecycle
464
+ clearCart, onExpired,
465
+ // Reactive state
466
+ status, // tagged union — see below
467
+ isLoading, error, // derived selectors over `status`
468
+ } = useCartManager(options?);
314
469
  ```
315
470
 
316
- ## GraphQL schema for codegen
471
+ **Per-operation recovery strategy** when a write hits a stale cart
472
+ (`userErrors[].code` ∈ `CART_NOT_FOUND` / `ALREADY_COMPLETED`):
317
473
 
318
- The GraphQL SDL ships in the linked `@doswiftly/storefront-operations` package
319
- (which is installed alongside the SDK) — point your codegen / IDE plugin at it
320
- directly. No live backend required:
474
+ | Strategy | Operations | Behaviour |
475
+ |---|---|---|
476
+ | **Auto-replay** | `addItem`, `updateBuyerIdentity`, `setShippingAddress`, `updateDiscountCodes`, `updateNote`, `updateAttributes` | Transparently recreates the cart via an atomic `cartCreate(input)` and resolves as success |
477
+ | **Bail + event** | `updateItem`, `removeItem`, `setBillingAddress`, `selectShippingMethod`, `selectPaymentMethod`, `clearPaymentSelection`, `applyGiftCard`, `removeGiftCard`, `updateGiftCardRecipient`, `complete` | Clears the cookie, throws `CartRecoveryNotPossibleError`, and fires every `onExpired` listener — subscribe once globally instead of try/catching every call site |
478
+ | **Out of scope** | `createPayment` | Operates on `orderId` (post-completion) — no cart to recover |
321
479
 
322
- ```ts
323
- // codegen.ts — uses raw .graphql file from the operations package
324
- const config = {
325
- schema: 'node_modules/@doswiftly/storefront-operations/schema.graphql',
326
- documents: ['./app/**/*.{ts,tsx,graphql}'],
327
- generates: { './generated/graphql.ts': { /* … */ } },
328
- };
329
- export default config;
480
+ **Status** is a tagged union for exhaustive rendering:
481
+
482
+ ```tsx
483
+ const { status } = useCartManager();
484
+ if (status.type === 'loading') return <Spinner label={status.operation} />;
485
+ if (status.type === 'error') return <ErrorBanner error={status.error} />;
486
+ // status.type ∈ { 'idle', 'success' }
330
487
  ```
331
488
 
332
- For VS Code GraphQL extension, point `graphql-config` at the same path.
489
+ **Options** (`UseCartManagerOptions`) all additive:
333
490
 
334
- ## Auth: focused hooks vs the facade
491
+ | Option | Purpose |
492
+ |---|---|
493
+ | `initialCartId` | Server-known cart-id seed used when the cookie is empty on mount (cookie wins). Accepts a bare id or the composite `"<cartId>.<secret>"` value — pass the composite when the secret is known server-side. Use cases: SSR checkout, magic-link, embedded iframe, customer-service "view this cart", multi-cart B2B. |
494
+ | `onMutationStart` / `onMutationSuccess` / `onMutationError` | Lifecycle callbacks around every operation — centralize toasts / router refresh / loading indicators. Cart expiry goes to `onExpired`, **not** `onMutationError`. |
495
+ | `cookieDebug` | Debug sink for `cart-id` cookie writes — see [Debug logging](#debug-logging). |
335
496
 
336
- Prefer the per-flow hooks in new code smaller bundles, isolated state:
497
+ ### `<CartManagerProvider>`one shared instance
498
+
499
+ `useCartManager` keeps per-mount state, so calling it in several components
500
+ creates independent managers. Wrap the subtree (inside `StorefrontProvider`) and
501
+ read the shared instance with `useCartManagerContext()`:
337
502
 
338
503
  ```tsx
339
- import { useLogin, useLogout, useRefreshToken } from '@doswiftly/storefront-sdk/react';
504
+ 'use client';
505
+ import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
506
+
507
+ <CartManagerProvider
508
+ initialCartId={initialCartId}
509
+ onMutationSuccess={() => router.refresh()}
510
+ onMutationError={(operation, error) => toast.error(error.message)}
511
+ >
512
+ <CheckoutForm />
513
+ </CartManagerProvider>;
340
514
 
341
- const { login, isLoggingIn } = useLogin({ onSetToken });
342
- const { logout, isLoggingOut } = useLogout({ onClearToken });
343
- const { refreshToken } = useRefreshToken({ onSetToken });
515
+ // CheckoutForm.tsx
516
+ const { addItem, complete, status } = useCartManagerContext();
344
517
  ```
345
518
 
346
- The legacy `useAuth` facade still works (combines all three):
519
+ For deliberately independent managers (multi-cart B2B, an admin "view this cart"
520
+ panel) call `useCartManager()` directly instead.
521
+
522
+ ### Checkout completion + payment
347
523
 
348
524
  ```tsx
349
- import { useAuth } from '@doswiftly/storefront-sdk/react';
350
- const { login, logout, refreshToken, isLoading, error } = useAuth({ onSetToken, onClearToken });
525
+ const { complete, createPayment } = useCartManagerContext();
526
+
527
+ // 1. Finalize the cart into an Order. On success the cart-id cookie is cleared
528
+ // and status resets to idle — a follow-up addItem creates a fresh cart.
529
+ const { order } = await complete();
530
+
531
+ // 2. Decide the payment flow from the Order itself — no hardcoded brand checks.
532
+ if (order.canCreatePayment) {
533
+ const session = await createPayment({
534
+ orderId: order.id,
535
+ returnUrl: `${origin}/checkout/return`, // optional — must point to a verified shop domain
536
+ });
537
+ // Branch on session.flow: redirectUrl (redirect), clientSecret (embedded
538
+ // widget), status (instant settlement). Failures throw with
539
+ // `err.userErrors[0].code` of the PAYMENT_* family.
540
+ }
351
541
  ```
352
542
 
353
- ## Core API
543
+ `getBrowserDataForPayment()` (from `/react`) is a standalone helper collecting
544
+ the browser context fields used by strong-customer-authentication flows
545
+ (user agent, screen, timezone, language); it throws
546
+ `BrowserDataNotAvailableError` outside the browser — call it in an event
547
+ handler, never during SSR.
354
548
 
355
- ### createStorefrontClient
549
+ The completed `order` also carries `order.accessToken` — an opaque token for
550
+ guest order lookup via `cartClient.getOrderByToken(token, email?)`.
551
+
552
+ ### Discovery queries (CartClient)
356
553
 
357
554
  ```typescript
358
- const client = createStorefrontClient({
359
- apiUrl: string,
360
- shopSlug: string,
361
- middleware?: Middleware[],
362
- defaultHeaders?: Record<string, string>,
363
- fetch?: typeof globalThis.fetch, // custom fetch (polyfill, test mocks)
364
- debug?: boolean, // log requests in dev
365
- });
555
+ const cartClient = new CartClient(client);
366
556
 
367
- client.query<T, V>(document, variables?, cache?): Promise<T>
368
- client.mutate<T, V>(document, variables?): Promise<T>
369
- client.use(middleware): void // imperative middleware add
370
- ```
557
+ // Cart-aware shipping preview for an address. Returns null when the cart is gone.
558
+ const payload = await cartClient.getAvailableShippingMethods(cartId, address);
559
+ if (payload) {
560
+ if (payload.userErrors.length > 0) {
561
+ // Backend business condition with a translated message
562
+ // (e.g. a digital-only cart needs no shipping).
563
+ show(payload.userErrors[0].message);
564
+ } else {
565
+ render(payload.methods, payload.freeShippingProgress);
566
+ // each method carries deliveryType: HOME | PICKUP_POINT | LOCKER
567
+ }
568
+ }
371
569
 
372
- Features: lazy pipeline compilation, same-tick request deduplication, TypedDocumentString support.
570
+ // Shop-level payment methods + the default pre-selection signal.
571
+ const { methods, defaultMethod } = await cartClient.getAvailablePaymentMethods();
373
572
 
374
- ### Middleware Pipeline
573
+ // Validate a discount code before applying it.
574
+ const result = await cartClient.validateDiscountCode(cartId, 'SAVE10');
575
+ if (!result.isValid) show(result.error?.message); // backend-translated
375
576
 
376
- Order matters: `auth currency [custom] retry → timeout → errors (LAST)`
577
+ // Guest order lookup by the opaque token from complete().
578
+ const order = await cartClient.getOrderByToken(token, email);
579
+ ```
377
580
 
378
- ```typescript
379
- import {
380
- authMiddleware, // Authorization: Bearer {token}
381
- currencyMiddleware, // X-Preferred-Currency header
382
- retryMiddleware, // Exponential backoff (queries only, not mutations)
383
- timeoutMiddleware, // AbortController, edge-safe (default 5s)
384
- errorMiddleware, // Normalizes all errors → StorefrontError (ALWAYS LAST)
385
- } from '@doswiftly/storefront-sdk';
581
+ Error-handling contract across the SDK (messages are **always**
582
+ backend-translated per `Accept-Language` — the SDK never synthesizes copy):
386
583
 
387
- // Custom middleware
388
- const logMiddleware: Middleware = async (req, next) => {
389
- console.log('Request:', req.operationName);
390
- const response = await next(req);
391
- console.log('Response:', response.status);
392
- return response;
393
- };
394
- ```
584
+ | Backend shape | SDK behaviour | You handle |
585
+ |---|---|---|
586
+ | Mutation with `userErrors[]` | Throws `StorefrontError`; `err.userErrors[0].message` is translated | `try/catch`, branch on `err.userErrors[0].code` |
587
+ | Nullable query root | Returns `T \| null` | `if (!result) …` |
588
+ | Structured payload (errors inside) | Returns the raw payload | Branch on `payload.userErrors[].code` / `payload.error.code` |
395
589
 
396
- ### CartClient
590
+ ### Auth ↔ cart lifecycle
397
591
 
398
592
  ```typescript
399
- const cartClient = new CartClient(client);
400
-
401
- const cart = await cartClient.create();
402
- const updated = await cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]);
403
- await cartClient.updateItems(cartId, [{ id: 'line-1', quantity: 3 }]);
404
- await cartClient.removeItems(cartId, ['line-1']);
405
- await cartClient.updateDiscountCodes(cartId, ['SAVE10']);
406
- await cartClient.updateNote(cartId, 'Gift message');
407
- await cartClient.updateBuyerIdentity(cartId, { email: 'user@example.com' });
408
- const existing = await cartClient.get(cartId);
409
- ```
593
+ // After a successful sign-in: merge the guest cart into the customer context.
594
+ // The secret is preserved — the same cookie keeps working.
595
+ await cartClient.merge(guestCartId);
410
596
 
411
- Auto-throws `StorefrontError` with code `USER_ERROR` on validation failures.
597
+ // On sign-out: strip customer PII from the cart (contact details, addresses,
598
+ // saved-payment selection). `useLogout` calls this automatically.
599
+ await cartClient.downgradeOnLogout(cartId);
412
600
 
413
- ### Cart recovery (stale carts)
601
+ // Cart recovery links (e.g. from an abandoned-cart email): redeem the token.
602
+ // Rotates the secret — persist the new composite cookie value.
603
+ const { cart, secret } = await cartClient.recoveryRedeem(token);
604
+ ```
414
605
 
415
- 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.
606
+ ### Cart recovery without React
416
607
 
417
- 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.
608
+ The same recovery semantics ship in core for Vue/Svelte/CLI/mobile consumers:
418
609
 
419
610
  ```typescript
420
611
  import {
@@ -425,117 +616,163 @@ import {
425
616
  type CartCookieStore,
426
617
  } from '@doswiftly/storefront-sdk';
427
618
 
428
- // Implement the cookie port for your runtime (or use createBrowserCartCookieStore from /react)
619
+ // Implement the cookie port for your runtime
620
+ // (browsers can use createBrowserCartCookieStore from /react).
429
621
  const cookieStore: CartCookieStore = {
430
- get: () => readCartCookie(),
431
- set: (cartId) => writeCartCookie(cartId),
622
+ get: () => readCartCookie(), // returns the cart id
623
+ set: (cartId, opts) => writeCartCookie(cartId, opts?.secret),
432
624
  clear: () => deleteCartCookie(),
433
625
  };
434
626
 
435
- const cartClient = new CartClient(client);
436
627
  const runner = createCartRecoveryRunner({ cartClient, cookieStore });
437
628
 
438
629
  runner.onExpired((event) => {
439
- // Fired when the runner cannot transparently recover. UI prompts user to retry.
440
- console.warn(`Cart expired (${event.reason}). Reset local state.`);
630
+ console.warn(`Cart expired (${event.reason}) reset local state.`);
441
631
  });
442
632
 
443
- // Auto-replay: storefront caller never thinks about recovery
633
+ // Auto-replay: the caller never thinks about stale carts.
444
634
  const { cart, warnings } = await runner.execute({
445
635
  name: 'addItems',
446
636
  run: (cartId) => cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]),
447
637
  recreateAndRun: recreateWithInput({ lines: [{ variantId: 'v-123', quantity: 1 }] }),
448
638
  });
449
639
 
450
- // Bail-on-stale: operation throws CartRecoveryNotPossibleError if cart is gone
451
- try {
452
- await runner.execute({
453
- name: 'updateItem',
454
- run: (cartId) => cartClient.updateItems(cartId, [{ id: 'line-1', quantity: 2 }]),
455
- // No recreateAndRun — replaying on an empty cart would silently lose the user's intent.
456
- });
457
- } catch (err) {
458
- if (err instanceof CartRecoveryNotPossibleError) {
459
- // Already handled by the onExpired listener above.
460
- } else {
461
- throw err;
462
- }
463
- }
640
+ // Bail-on-stale: no recreateAndRun — throws CartRecoveryNotPossibleError instead.
464
641
  ```
465
642
 
466
- Detection inspects `err.userErrors[].code` (`CART_NOT_FOUND` / `ALREADY_COMPLETED`) — locale-independent.
643
+ Detection inspects `err.userErrors[].code` (`CART_NOT_FOUND` /
644
+ `ALREADY_COMPLETED`) — locale-independent. The runner also creates the cart on
645
+ first use (deduped across concurrent calls) and persists the revealed secret
646
+ through the cookie store.
467
647
 
468
- ### React: `useCartManager` (DX-first hook)
648
+ ### `useCart(cartId)` — server-driven cart
469
649
 
470
- `useCartManager` is the React wrapper around the recovery runner same per-operation taxonomy, just `useState`-driven loading/error and an `onExpired` subscription helper:
650
+ Sister of `useCartManager` bound to an **explicit** `cartId` propnever touches
651
+ the cookie, no auto-recovery. For SSR-rendered checkout, deep-link order
652
+ recovery, and admin "view this cart" UIs:
471
653
 
472
654
  ```tsx
473
655
  'use client';
474
- import { useEffect } from 'react';
475
- import { useCartManager } from '@doswiftly/storefront-sdk/react';
476
- import { toast } from 'sonner';
656
+ import { useCart } from '@doswiftly/storefront-sdk/react';
657
+
658
+ const {
659
+ cart, isLoading, error, operation,
660
+ refetch,
661
+ addItems, updateItems, removeItems,
662
+ updateBuyerIdentity, setShippingAddress,
663
+ updateDiscountCodes, updateNote, updateAttributes,
664
+ } = useCart(cartId, {
665
+ autoFetch: false, // skip the mount fetch when the server already rendered the cart
666
+ initialCart, // SSR seed — combine with autoFetch: false
667
+ });
668
+ ```
477
669
 
478
- export function CartButton() {
479
- const { addItem, updateItem, onExpired, isLoading } = useCartManager();
670
+ ## Pre-built React components
480
671
 
481
- useEffect(
482
- () => onExpired((e) => toast.error(e.reason === 'state-dependent' ? 'Twój koszyk wygasł, dodaj produkty ponownie' : 'Nie udało się odzyskać koszyka')),
483
- [onExpired],
484
- );
672
+ Headless, accessibility-aware, zero styling — pass `className` to integrate with
673
+ your CSS approach. Available from `@doswiftly/storefront-sdk/react`:
485
674
 
486
- return (
487
- <button onClick={() => addItem([{ variantId: 'v-123', quantity: 1 }])} disabled={isLoading}>
488
- Add to cart
489
- </button>
490
- );
491
- }
675
+ | Component | Purpose |
676
+ |-----------|---------|
677
+ | `<Money amount currency>` | Locale-formatted price string from minor units |
678
+ | `<Image data sizes priority>` | `<img>` with thumbhash blur placeholder + sane defaults |
679
+ | `<CartCount count label>` | Aria-live cart item count |
680
+ | `<AddToCartButton variantId quantity>` | Button wired to `useCartManager().addItem` (loading state + a11y error surfacing) |
681
+ | `<PriceDisplay price compareAtPrice currency>` | Price + optional strikethrough sale price |
682
+ | `<CartTotals subtotal tax shipping discount total currency>` | Cart financial breakdown `<dl>` |
683
+ | `<PaymentInstrumentTile instrument>` | One selectable payment instrument (card brand, wallet, bank) |
684
+ | `<PaymentInstrumentSection method>` | Instrument group for a payment method (renders tiles) |
685
+
686
+ ```tsx
687
+ import { Money, PriceDisplay, CartCount } from '@doswiftly/storefront-sdk/react';
688
+
689
+ <Money amount={9990} currency="PLN" /> {/* "99,90 zł" */}
690
+ <PriceDisplay price={7990} compareAtPrice={9990} currency="PLN" />
691
+ <CartCount count={3} label="items" />
492
692
  ```
493
693
 
494
- Methods returning `CartMutationOutcome` are: `addItem`, `updateBuyerIdentity`, `setShippingAddress`, `updateDiscountCodes`, `updateNote` (auto-replay) and `updateItem`, `removeItem` (bail + `onExpired`). Use `clearCart` to reset locally without backend call.
694
+ ## Middleware pipeline
495
695
 
496
- ### React: `<CartManagerProvider>` (shared instance)
696
+ Default order (wired automatically by `StorefrontProvider`):
497
697
 
498
- `useCartManager` keeps per-mount state, so calling it in several components creates independent managers. To share one source of truth across a checkout — one loading state, one recovery queue, one `cart-expired` subscription — wrap the subtree in `<CartManagerProvider>` (inside `<StorefrontProvider>`) and read it with `useCartManagerContext()`. The provider also takes lifecycle callbacks so cross-cutting side-effects live in one place:
698
+ ```
699
+ auth → cart-secret → currency → language → bot-protection → [custom] → retry → timeout → errors (ALWAYS LAST)
700
+ ```
499
701
 
500
- ```tsx
501
- 'use client';
502
- import { useRouter } from 'next/navigation';
503
- import { toast } from 'sonner';
504
- import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
702
+ When session refresh is active, a reactive-401 middleware wraps the whole
703
+ pipeline: a 401 on a read query triggers one deduped `refreshSession()` and a
704
+ replay; a 401 on a mutation fires the `session-expired` signal instead.
505
705
 
506
- function Checkout({ initialCartId }: { initialCartId: string | null }) {
507
- const router = useRouter();
508
- return (
509
- <CartManagerProvider
510
- initialCartId={initialCartId}
511
- onMutationSuccess={() => router.refresh()}
512
- onMutationError={(operation, error) => toast.error(error.message)}
513
- >
514
- <CheckoutForm />
515
- </CartManagerProvider>
516
- );
517
- }
706
+ ```typescript
707
+ import {
708
+ authMiddleware, // Authorization: Bearer <token> (lazy getter)
709
+ cartSecretMiddleware, // x-cart-secret header (lazy getter)
710
+ currencyMiddleware, // X-Preferred-Currency header
711
+ languageMiddleware, // X-Language header (skipped when null — intentional)
712
+ botProtectionMiddleware, // challenge token for protected mutations only
713
+ retryMiddleware, // exponential backoff + jitter — queries only, never mutations
714
+ timeoutMiddleware, // AbortController, edge-safe (default 5s)
715
+ errorMiddleware, // normalizes all errors → StorefrontError (ALWAYS LAST)
716
+ sessionRetryMiddleware, // reactive-401 refresh + replay (queries only)
717
+ } from '@doswiftly/storefront-sdk';
518
718
 
519
- function CheckoutForm() {
520
- const { addItem, complete, status } = useCartManagerContext();
521
- // ...
522
- }
719
+ // Custom middleware
720
+ const logMiddleware: Middleware = async (request, next) => {
721
+ console.log('Request:', request.operationName);
722
+ const response = await next(request);
723
+ return response;
724
+ };
523
725
  ```
524
726
 
525
- For deliberately independent managers (multi-cart B2B, an admin "view this cart" panel) call `useCartManager()` directly instead.
727
+ Middleware that reads mutable state takes a **lazy getter**
728
+ (`authMiddleware(() => store.getState().accessToken)`) so rotated values are
729
+ picked up without rebuilding the client.
526
730
 
527
- ### AuthClient
731
+ ## Core API
732
+
733
+ ### createStorefrontClient
528
734
 
529
735
  ```typescript
530
- const authClient = new AuthClient(client);
736
+ const client = createStorefrontClient({
737
+ apiUrl: string,
738
+ shopSlug: string,
739
+ middleware?: Middleware[],
740
+ defaultHeaders?: Record<string, string>,
741
+ fetch?: typeof globalThis.fetch, // custom fetch (polyfill, test mocks)
742
+ debug?: boolean | 'verbose' | DebugOptions,
743
+ });
744
+
745
+ client.query<T, V>(document, variables?, cache?): Promise<T>
746
+ client.mutate<T, V>(document, variables?): Promise<T>
747
+ client.use(middleware): void // imperative middleware add
748
+ ```
749
+
750
+ Features: lazy pipeline compilation, same-tick request deduplication (queries
751
+ only), `TypedDocumentString` support from graphql-codegen.
531
752
 
532
- const { accessToken, expiresAt } = await authClient.login('user@example.com', 'pass');
533
- await authClient.logout();
534
- const renewed = await authClient.refreshToken();
535
- const { accessToken: regToken, customer } = await authClient.register({ email, password, firstName });
536
- const me = await authClient.getCustomer();
753
+ ### Debug logging
754
+
755
+ ```typescript
756
+ createStorefrontClient({ apiUrl, shopSlug, debug: 'verbose' });
537
757
  ```
538
758
 
759
+ - `debug: true` — minimal: operation name + variables on request, status +
760
+ userErrors on response.
761
+ - `debug: 'verbose'` — everything: full query, variables, headers, response
762
+ body, timing, userErrors.
763
+ - `debug: { request?, response?, headers?, timing?, userErrors?, log? }` —
764
+ granular per-dimension opt-in, plus a custom sink
765
+ (`log: (event: DebugEvent) => void`) for routing into your logger.
766
+ - Env fallback: `DOSWIFTLY_SDK_DEBUG=verbose|true|minimal` when the option is
767
+ omitted — disabled in `NODE_ENV=production` (PII safety).
768
+ - `Authorization: Bearer …` and auth-cookie values are **unconditionally
769
+ redacted** to `***<last4>` whenever headers are logged.
770
+
771
+ `createRemoteDebugTransport` builds a shared remote sink — pass it as
772
+ `debug: { remote: transport }` on the client and as `cookieDebug` on the
773
+ providers so GraphQL operations and cookie writes land on one timeline with a
774
+ single session id.
775
+
539
776
  ### StorefrontError
540
777
 
541
778
  ```typescript
@@ -545,10 +782,10 @@ try {
545
782
  await client.query(ProductQuery, { handle: 'missing' });
546
783
  } catch (err) {
547
784
  if (err instanceof StorefrontError) {
548
- err.code; // 'GRAPHQL_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'USER_ERROR' | ...
785
+ err.code; // 'GRAPHQL_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'USER_ERROR' | 'SESSION_EXPIRED' | ...
549
786
  err.status; // HTTP status (0 for network errors)
550
787
  err.graphqlErrors; // GraphQL-level errors
551
- err.userErrors; // Field-level validation errors
788
+ err.userErrors; // field-level validation errors (backend-translated messages)
552
789
  err.hasUserErrors; // boolean
553
790
  err.isNetworkError; // boolean
554
791
  err.isTimeout; // boolean
@@ -556,274 +793,284 @@ try {
556
793
  }
557
794
  ```
558
795
 
559
- ### Format Utilities
796
+ `assertNoUserErrors(payload)` the helper the clients use internally — is also
797
+ exported for custom operations: it throws a `StorefrontError` carrying the first
798
+ backend-translated `userErrors[].message`.
799
+
800
+ ### Schema enums — runtime constants
801
+
802
+ Every schema enum is exported as a **runtime const + type alias pair**, so both
803
+ `import type { DeliveryType }` and `Object.values(DeliveryType)` work:
804
+
805
+ ```typescript
806
+ import { DeliveryType, PaymentMethodType, CountryCode } from '@doswiftly/storefront-sdk';
807
+
808
+ const schema = z.enum(Object.values(PaymentMethodType)); // runtime validation
809
+ type T = DeliveryType; // 'HOME' | 'PICKUP_POINT' | 'LOCKER'
810
+ ```
811
+
812
+ Available: `DeliveryType`, `PaymentMethodType`, `PaymentInitiationFlow`,
813
+ `CurrencyCode`, `CountryCode`, `LanguageCode`, `ProductTypeEnum`, `WeightUnit`,
814
+ `CartWarningCode`, `AttributeType`, `AttributeFillingMode`,
815
+ `AttributeBillingMode`, `AttributeOptionSurchargeType`, `StorefrontOrderStatus`,
816
+ `OrderPaymentStatus`, `OrderFulfillmentStatus`, `DiscountErrorCode`,
817
+ `DiscountApplicationType`, `PaymentProvider`, `PaymentInstrumentType`,
818
+ `PaymentInstrumentDisplayHint`, `PaymentMethodUnavailableReason`.
819
+
820
+ ### Format utilities
560
821
 
561
822
  ```typescript
562
823
  import {
563
- formatPrice,
564
- formatPriceRange,
565
- formatAmount,
566
- formatDate,
567
- formatDateTime,
568
- formatNumber,
569
- formatPercentage,
824
+ formatPrice, formatPriceRange, formatAmount,
825
+ formatDate, formatDateTime, formatNumber, formatPercentage,
570
826
  getCurrencySymbol,
571
- CURRENCY_SYMBOLS,
572
- CURRENCY_LOCALES,
573
827
  } from '@doswiftly/storefront-sdk';
574
828
 
575
829
  formatPrice({ amount: '99.99', currencyCode: 'USD' }); // "$99.99"
576
- formatPriceRange(minPrice, maxPrice); // "$10.00 - $50.00"
577
830
  formatAmount('115.20', 'EUR'); // "115,20 €"
578
- formatDate(new Date()); // "Dec 9, 2025"
579
- formatPercentage(0.15); // "15%"
831
+ formatPercentage(0.15); // "15%"
580
832
  ```
581
833
 
582
- ### Image Types
583
-
584
- GraphQL API returns ready-to-use CDN URLs via `url(transform: { maxWidth: 800 })`. No client-side loader needed.
834
+ Locale-bound versions that follow the active storefront language are available
835
+ as React hooks — see [Format hooks](#format-hooks).
585
836
 
586
- `ImageData` type matches GraphQL `Image` fragment: `{ url, altText?, width?, height?, id? }`.
587
-
588
- ### HTML Sanitizer
837
+ ### HTML sanitizer + connection normalizer
589
838
 
590
839
  ```typescript
591
- import { sanitizeHtml } from '@doswiftly/storefront-sdk';
840
+ import { sanitizeHtml, normalizeConnection } from '@doswiftly/storefront-sdk';
592
841
 
593
- // Defense-in-depth: strips <script>, event handlers, javascript: URLs
594
- const safe = sanitizeHtml(userHtml);
595
- ```
842
+ const safe = sanitizeHtml(userHtml); // strips <script>, event handlers, javascript: URLs
596
843
 
597
- ### Connection Normalizer
844
+ const { items, pageInfo, totalCount } = normalizeConnection(data.products); // Relay → flat array
845
+ ```
598
846
 
599
- ```typescript
600
- import { normalizeConnection } from '@doswiftly/storefront-sdk';
847
+ ### Cookie contracts (platform constants)
601
848
 
602
- // Relay connection flat array
603
- const { items, pageInfo, totalCount } = normalizeConnection(data.products);
604
- ```
849
+ All first-party cookie names/defaults the platform relies on are exported —
850
+ never hardcode the strings:
605
851
 
606
- ### Auth Cookie Config (Platform Contract)
852
+ | Constant | Cookie | Notes |
853
+ |---|---|---|
854
+ | `AUTH_COOKIE_NAME` / `AUTH_COOKIE_DEFAULTS` | `customerAccessToken` | httpOnly access token |
855
+ | `REFRESH_COOKIE_NAME` / `REFRESH_COOKIE_DEFAULTS` | `customerRefreshToken` | httpOnly, path-scoped to the auth route |
856
+ | `SESSION_EXPIRY_COOKIE_NAME` / `SESSION_EXPIRY_COOKIE_DEFAULTS` | `session-expiry` | readable expiry hint for the scheduler |
857
+ | `CART_COOKIE_NAME` / `CART_COOKIE_MAX_AGE` | `cart-id` | composite `"<cartId>.<secret>"`, 30 days |
858
+ | `CURRENCY_COOKIE_NAME` / `CURRENCY_COOKIE_MAX_AGE` / `CURRENCY_HEADER_NAME` | `preferred-currency` | |
859
+ | `LANGUAGE_COOKIE_NAME` / `LANGUAGE_COOKIE_MAX_AGE` / `LANGUAGE_HEADER_NAME` | `preferred-language` | |
607
860
 
608
861
  ```typescript
609
- import { AUTH_COOKIE_NAME, AUTH_COOKIE_DEFAULTS } from '@doswiftly/storefront-sdk';
862
+ import { parseCartCookieValue, formatCartCookieValue } from '@doswiftly/storefront-sdk';
610
863
 
611
- // AUTH_COOKIE_NAME = 'customerAccessToken'
612
- // AUTH_COOKIE_DEFAULTS = { name, path, sameSite, httpOnly, secure, maxAge }
864
+ parseCartCookieValue('abc.s3cret'); // { cartId: 'abc', cartSecret: 's3cret' }
865
+ parseCartCookieValue('abc'); // { cartId: 'abc', cartSecret: null } (legacy)
866
+ formatCartCookieValue({ cartId: 'abc', cartSecret: 's3cret' }); // 'abc.s3cret'
613
867
  ```
614
868
 
615
- ### Auth Cookie Handlers (API Route Factories)
869
+ ### Route matching
616
870
 
617
871
  ```typescript
618
- import { createSetTokenHandler, createClearTokenHandler } from '@doswiftly/storefront-sdk';
619
-
620
- // Next.js API route (2 lines):
621
- // app/api/auth/set-token/route.ts
622
- export const POST = createSetTokenHandler();
872
+ import { matchesRoute } from '@doswiftly/storefront-sdk';
623
873
 
624
- // app/api/auth/clear-token/route.ts
625
- export const POST = createClearTokenHandler();
874
+ matchesRoute('/account/orders', ['/account']); // true (exact + prefix matching)
626
875
  ```
627
876
 
628
- Uses pure Web API (Request/Response) — 0 deps, framework-agnostic.
629
- Security: origin validation, Content-Type check, CSRF via SameSite=Lax, httpOnly cookie.
877
+ ## React adapter
630
878
 
631
- ### Auth Token Client (Client-side)
879
+ ### `<StorefrontProvider>` props
632
880
 
633
- ```typescript
634
- import { createAuthTokenClient } from '@doswiftly/storefront-sdk';
881
+ | Prop | Type | Purpose |
882
+ |---|---|---|
883
+ | `config` | `{ apiUrl, shopSlug, … }` | Client config (full `StorefrontClientConfig`, incl. `debug`) |
884
+ | `shopData` | `ShopConfig` | `currencyCode`, `supportedCurrencies`, `localeToCurrencyMap?`, `defaultLanguage?`, `supportedLanguages?`, `botProtection?` |
885
+ | `middleware` | `Middleware[]` | Extra middleware, inserted after the built-in header middleware |
886
+ | `initialIsAuthenticated` | `boolean` | Server-side auth hint — no "Sign in" flash. Defaults to `!!initialAccessToken` |
887
+ | `initialAccessToken` | `string \| null` | Server-side raw JWT seed (kept in memory, never persisted) |
888
+ | `initialExpiresAt` | `string \| null` | Session expiry seed (ISO 8601) — arms the refresh scheduler on cold start |
889
+ | `initialLanguage` | `string` | Server-side locale hint — no language flash |
890
+ | `autoRefresh` | `boolean` | Proactive session refresh — default ON in the browser |
891
+ | `authBasePath` | `string` | Where the BFF auth route is mounted (default `/api/auth`) |
892
+ | `cookieDebug` | `(event: DebugEvent) => void` | Debug sink for currency/language cookie writes |
635
893
 
636
- const { setToken, clearToken } = createAuthTokenClient();
637
- await setToken(accessToken); // POST /api/auth/set-token
638
- await clearToken(); // POST /api/auth/clear-token
639
- ```
640
-
641
- ### Route Matching
894
+ The provider creates all store instances per mount (no module-level singletons —
895
+ safe under bundler module duplication), wires the default middleware pipeline,
896
+ mounts the bot-protection widget when the shop has it configured, and runs the
897
+ session-refresh scheduler.
642
898
 
643
- ```typescript
644
- import { matchesRoute } from '@doswiftly/storefront-sdk';
899
+ `StorefrontClientProvider`, `CurrencyProvider`, `LanguageProvider` are exported
900
+ separately for custom composition.
645
901
 
646
- // Supports exact and prefix matching
647
- matchesRoute('/account/orders', ['/account']); // true
648
- matchesRoute('/products', ['/account']); // false
649
- ```
902
+ ### Stores (Context-based)
650
903
 
651
- ### Cache Strategies
904
+ All store hooks require the `StorefrontProvider` wrapper and accept an optional
905
+ selector:
652
906
 
653
907
  ```typescript
654
- import { cacheLong, cacheShort, cacheNone, cachePrivate, cacheCustom } from '@doswiftly/storefront-sdk/cache';
655
-
656
- cacheLong() // 1h + 23h stale-while-revalidate
657
- cacheLong({ tags: ['product', slug] }) // with Next.js revalidation tags
658
- cacheShort() // 1s + 9s swr
659
- cacheNone() // no-store
660
- cachePrivate() // private, 1s + 9s swr
661
- cacheCustom({ maxAge: 300, swr: 600 }) // 5min + 10min swr
662
- ```
908
+ import {
909
+ useAuthStore, useAuthStoreApi, useAuthHydrated,
910
+ useCurrencyStore, useCurrencyStoreApi,
911
+ useLanguageStore, useLanguageStoreApi,
912
+ } from '@doswiftly/storefront-sdk/react';
913
+
914
+ // Auth
915
+ const { isAuthenticated, customer, accessToken, expiresAt, setAuth, clearAuth } = useAuthStore();
916
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // with selector
917
+ const authHydrated = useAuthHydrated(); // true after localStorage rehydration
663
918
 
664
- ## React Adapter
919
+ // Currency
920
+ const { currency, baseCurrency, supportedCurrencies, setCurrency, isLoaded } = useCurrencyStore();
665
921
 
666
- ### Providers
922
+ // Language
923
+ const { language, setLanguage } = useLanguageStore();
667
924
 
668
- ```tsx
669
- // Convenience (recommended)
670
- <StorefrontProvider config={{ apiUrl, shopSlug }} shopData={shop}>
671
- {children}
672
- </StorefrontProvider>
925
+ // `*StoreApi` variants return the raw store for .getState() in callbacks
926
+ const token = useAuthStoreApi().getState().accessToken;
673
927
  ```
674
928
 
675
- `StorefrontProvider` creates Zustand store instances via `useRef` and provides them through React Context. This eliminates module-level singleton issues with Turbopack/bundler module duplication.
929
+ Pre-built selectors: `selectCurrency`, `selectBaseCurrency`,
930
+ `selectSupportedCurrencies`, `selectIsLoaded`, `selectLanguage`,
931
+ `selectDefaultLanguage`, `selectSupportedLanguages`, `selectLanguageIsLoaded`.
676
932
 
677
- ### useAuth
933
+ **Security note:** the access token is **never** persisted to
934
+ `localStorage`/`sessionStorage` — it lives in memory (and in the httpOnly
935
+ cookie). Persisted auth state covers only `customer` + `isAuthenticated`.
678
936
 
679
- ```typescript
680
- const {
681
- login, // (email, password) => Promise<LoginResult>
682
- logout, // () => Promise<LogoutResult>
683
- refreshToken, // () => Promise<TokenRefreshResult>
684
- isLoggingIn, isLoggingOut, isRefreshingToken, isLoading,
685
- error,
686
- } = useAuth({
687
- onSetToken: async (token) => { /* set httpOnly cookie via server route */ },
688
- onClearToken: async () => { /* clear httpOnly cookie */ },
689
- });
690
- ```
937
+ `useCurrency()` is a convenience aggregator over the currency store.
938
+
939
+ ### Format hooks
691
940
 
692
- ### Cart Store (DI-based) recommended for templates
941
+ Locale-aware formatters bound to the active storefront language:
693
942
 
694
943
  ```typescript
695
- import { createCartStore, CartProvider, useCartStore, type CartActions } from '@doswiftly/storefront-sdk/react';
944
+ import {
945
+ useFormatPrice, useFormatAmount, useFormatPriceRange,
946
+ useFormatDate, useFormatDateTime, useFormatNumber, useGetCurrencySymbol,
947
+ } from '@doswiftly/storefront-sdk/react';
696
948
 
697
- // Template provides CartActions implementation (transport layer)
698
- const store = createCartStore({
699
- getActions: () => cartActions, // getter — called per operation (fresh refs)
700
- onMutationSuccess: (action, cart) => { /* toast, cache invalidation */ },
701
- onMutationError: (action, error) => { /* error toast */ },
702
- });
949
+ const formatPrice = useFormatPrice();
950
+ formatPrice({ amount: '99.99', currencyCode: 'PLN' }); // honors the active locale
951
+ ```
703
952
 
704
- // Provider (separate from StorefrontProvider — template composes in StoresProvider)
705
- <CartProvider store={store}>{children}</CartProvider>
953
+ ### Generic hooks
706
954
 
707
- // Hooks (Context-based, selector overloads)
708
- const { cartId, isOpen, isLoading, addToCart, clearCart, openCart } = useCartStore();
709
- const cartId = useCartStore(s => s.cartId); // with selector
955
+ ```typescript
956
+ import { useHydrated, useDebouncedValue, useStorefrontClient } from '@doswiftly/storefront-sdk/react';
710
957
 
711
- // Selectors
712
- import { selectCartId, selectIsCartOpen, selectCartIsLoading } from '@doswiftly/storefront-sdk/react';
958
+ const isHydrated = useHydrated(); // false during SSR + first client render
959
+ const debounced = useDebouncedValue(query, 300);
960
+ const client = useStorefrontClient(); // the StorefrontClient from context
713
961
  ```
714
962
 
715
- SDK orchestrates: auto-init (fetch or create), expired cart recovery, loading/error state, mutation callbacks.
716
- Template provides: CartActions DI implementation (GraphQL hooks, React Query, fetch — any transport).
963
+ ### `createStoreContext`
717
964
 
718
- ### useCartManager (alternative — cookie-based, simple)
965
+ Build your own Context-based Zustand stores (the same pattern the SDK uses —
966
+ no module-level singletons):
719
967
 
720
968
  ```typescript
721
- const {
722
- getCart, // () => Promise<Cart | null>
723
- addItem, // (lines: CartLineInput[]) => Promise<Cart>
724
- updateItem, // (lines: CartLineUpdateInput[]) => Promise<Cart>
725
- removeItem, // (lineIds: string[]) => Promise<Cart>
726
- updateDiscountCodes, updateNote, clearCart, getCartId,
727
- isLoading, error,
728
- } = useCartManager();
969
+ import { createStoreContext } from '@doswiftly/storefront-sdk/react';
970
+
971
+ const { Provider: WishlistProvider, useStore: useWishlistStore, useApi: useWishlistStoreApi } =
972
+ createStoreContext<WishlistState>('WishlistStore');
729
973
  ```
730
974
 
731
- Cart ID is persisted in a cookie (SSR/edge visible). Plain async + useState, no React Query dependency.
975
+ ### Bot protection
732
976
 
733
- ### useHydrated
977
+ When the shop has bot protection configured (`shopData.botProtection`), the
978
+ provider loads the challenge widget and `botProtectionMiddleware` attaches
979
+ tokens to protected mutations automatically. `useBotProtection()` exposes
980
+ `execute()` for manual token acquisition; `createBotProtectionManager` /
981
+ `FallbackBotProtectionManager` are exported from core for custom wiring.
982
+ Fail-open by default — a challenge outage never blocks checkout.
734
983
 
735
- ```typescript
736
- import { useHydrated } from '@doswiftly/storefront-sdk/react';
984
+ ## Server-side (`/react/server`)
737
985
 
738
- const isHydrated = useHydrated();
739
- // false during SSR and first client render, true after hydration
740
- // Use to guard browser-only state (localStorage, cookies, window)
986
+ ```typescript
987
+ import {
988
+ getStorefrontClient, // server client factory (10s timeout, retry, errors)
989
+ createStorefrontAuthRoute, // SDK-BFF auth route — see Authentication
990
+ getInitialAuth, // cold-start auth seed from first-party cookies (Next.js)
991
+ readCartIdCookie, // cart id (string | null)
992
+ readCartCredentials, // { cartId, cartSecret } | null — composite cookie
993
+ readCurrencyCookie, // preferred currency (string | null)
994
+ serverCartSecretMiddleware, // attach x-cart-secret on SSR/edge cart reads
995
+ trustedForwardedHostValidator,
996
+ originAllowlistValidator,
997
+ } from '@doswiftly/storefront-sdk/react/server';
741
998
  ```
742
999
 
743
- ### useDebouncedValue
1000
+ SSR cart read with the capability secret:
744
1001
 
745
1002
  ```typescript
746
- import { useDebouncedValue } from '@doswiftly/storefront-sdk/react';
1003
+ // Server Component / Route Handler
1004
+ const credentials = await readCartCredentials();
747
1005
 
748
- const debouncedQuery = useDebouncedValue(query, 300);
1006
+ const client = getStorefrontClient({
1007
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
1008
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
1009
+ middleware: [serverCartSecretMiddleware(credentials)],
1010
+ });
1011
+
1012
+ const cart = credentials ? await new CartClient(client).get(credentials.cartId) : null;
749
1013
  ```
750
1014
 
751
- ### createStoreContext
1015
+ The server factory ships **without** auth/currency middleware (there are no
1016
+ stores on the server) — add request-scoped headers via custom middleware. For
1017
+ authenticated SSR reads, forward the access token from the httpOnly cookie:
752
1018
 
753
1019
  ```typescript
754
- import { createStoreContext } from '@doswiftly/storefront-sdk/react';
755
- import { createStore } from 'zustand/vanilla';
756
-
757
- // Define store factory + Context-based hooks (eliminates module-level singletons)
758
- const { Provider: CartProvider, useStore: useCartStore, useApi: useCartStoreApi } =
759
- createStoreContext<CartState>('CartStore');
760
-
761
- // In layout:
762
- const cartStore = useRef(createCartStore()).current;
763
- <CartProvider store={cartStore}>{children}</CartProvider>
764
-
765
- // In components:
766
- const isOpen = useCartStore((s) => s.isOpen);
767
- const api = useCartStoreApi(); // for .getState() in callbacks
1020
+ middleware: [
1021
+ async (request, next) => {
1022
+ const token = (await cookies()).get(AUTH_COOKIE_NAME)?.value;
1023
+ if (token) request.headers['Authorization'] = `Bearer ${token}`;
1024
+ return next(request);
1025
+ },
1026
+ ],
768
1027
  ```
769
1028
 
770
- ### Zustand Stores (Context-based)
771
-
772
- Stores use `createStore()` from `zustand/vanilla` + React Context pattern. All store hooks require `StorefrontProvider` wrapper.
1029
+ ## Caching
773
1030
 
774
1031
  ```typescript
775
- import { useAuthStore, useAuthHydrated, useCurrencyStore } from '@doswiftly/storefront-sdk/react';
776
-
777
- // Auth — state
778
- const { isAuthenticated, customer, accessToken } = useAuthStore();
779
- // Auth — with selector
780
- const isAuthenticated = useAuthStore(s => s.isAuthenticated);
781
- // Auth — persist hydration (replaces old isHydrated store field)
782
- const authHydrated = useAuthHydrated(); // true after localStorage rehydration
1032
+ import { cacheLong, cacheShort, cacheNone, cachePrivate, cacheCustom } from '@doswiftly/storefront-sdk/cache';
783
1033
 
784
- // Currency
785
- const { currency, baseCurrency, supportedCurrencies, setCurrency, isLoaded } = useCurrencyStore();
1034
+ cacheLong() // 1h + 23h stale-while-revalidate
1035
+ cacheLong({ tags: ['product', slug] }) // with Next.js revalidation tags
1036
+ cacheShort() // 1s + 9s swr
1037
+ cacheNone() // no-store
1038
+ cachePrivate() // private, 1s + 9s swr
1039
+ cacheCustom({ maxAge: 300, swr: 600 }) // 5min + 10min swr
786
1040
 
787
- // For .getState() in callbacks (e.g. logout, refreshToken)
788
- import { useAuthStoreApi, useCurrencyStoreApi } from '@doswiftly/storefront-sdk/react';
789
- const authStore = useAuthStoreApi();
790
- const token = authStore.getState().accessToken;
1041
+ const data = await client.query(ProductQuery, { handle }, cacheLong());
791
1042
  ```
792
1043
 
793
- ### Server-side
1044
+ ## GraphQL schema for codegen
794
1045
 
795
- ```typescript
796
- import { getStorefrontClient } from '@doswiftly/storefront-sdk/react/server';
1046
+ The GraphQL SDL ships in the linked `@doswiftly/storefront-operations` package
1047
+ (installed alongside the SDK) point your codegen / IDE plugin at it directly.
1048
+ No live backend required:
797
1049
 
798
- // Server Component or Route Handler
799
- const client = getStorefrontClient({
800
- apiUrl: process.env.API_URL!,
801
- shopSlug: process.env.SHOP_SLUG!,
802
- });
1050
+ ```ts
1051
+ // codegen.ts uses the raw .graphql file from the operations package
1052
+ const config = {
1053
+ schema: 'node_modules/@doswiftly/storefront-operations/schema.graphql',
1054
+ documents: ['./app/**/*.{ts,tsx,graphql}'],
1055
+ generates: { './generated/graphql.ts': { /* … */ } },
1056
+ };
1057
+ export default config;
803
1058
  ```
804
1059
 
805
- ## Template Integration
1060
+ For the VS Code GraphQL extension, point `graphql-config` at the same path.
1061
+ `client.query`/`client.mutate` accept the generated `TypedDocumentString`
1062
+ documents directly.
806
1063
 
807
- Storefronts (Next.js templates) import SDK for **infrastructure** and own their **data-fetching layer**:
1064
+ ## Deprecated
808
1065
 
809
- ```
810
- SDK provides: Template owns:
811
- ├── Transport + Middleware ├── codegen.ts + generated/graphql.ts
812
- ├── CartClient + AuthClient ├── lib/graphql/hooks.ts (React Query)
813
- ├── Cart Store (DI-based) ├── lib/graphql/server.ts (React cache)
814
- ├── Providers + Zustand stores ├── lib/graphql/fragments/
815
- ├── Format utilities ├── hooks/use-cart-di.ts (CartActions DI impl)
816
- ├── sanitizeHtml ├── hooks/use-cart-actions.ts (UX wrapper)
817
- ├── ImageData type ├── components/product/product-image.tsx
818
- ├── normalizeConnection ├── stores/ (checkout, wishlist via createStoreContext)
819
- ├── Auth handlers + token client ├── lib/auth/routes.ts (route config)
820
- ├── useHydrated + useDebouncedValue
821
- ├── createStoreContext └── components/providers/
822
- ├── AUTH_COOKIE_NAME + matchesRoute
823
- └── Cache strategies
824
- ```
1066
+ | Symbol | Replacement |
1067
+ |---|---|
1068
+ | `createCartStore`, `CartProvider`, `useCartStore`, `useCartStoreApi` | `useCartManager` / `<CartManagerProvider>` — the legacy store predates capability carts and does not carry the cart secret |
1069
+ | `AuthClient.refreshToken()`, `useRefreshToken` | Automatic refresh (`autoRefresh`) / `AuthClient.refreshSession()` — the GraphQL refresh requires a still-valid access token |
1070
+ | `ShopCurrencyData` | `ShopConfig` |
825
1071
 
826
- Data-fetching hooks are generated locally via `@graphql-codegen/client-preset` (TypedDocumentString).
1072
+ Deprecated symbols remain exported for backward compatibility and will be
1073
+ removed in a future major release.
827
1074
 
828
1075
  ## License
829
1076