@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.
- package/CHANGELOG.md +19 -0
- package/README.md +776 -529
- package/dist/core/auth/handlers.d.ts +10 -9
- package/dist/core/auth/handlers.d.ts.map +1 -1
- package/dist/core/auth/handlers.js +10 -9
- package/dist/core/auth/session-events.d.ts +2 -2
- package/dist/core/auth/session-events.js +2 -2
- package/dist/core/cart/cart-client.d.ts +23 -24
- package/dist/core/cart/cart-client.d.ts.map +1 -1
- package/dist/core/cart/cart-client.js +24 -25
- package/dist/core/generated/operation-types.d.ts +50 -50
- package/dist/core/generated/operation-types.d.ts.map +1 -1
- package/dist/core/middleware/session-retry.d.ts +5 -6
- package/dist/core/middleware/session-retry.d.ts.map +1 -1
- package/dist/core/middleware/session-retry.js +7 -8
- package/dist/core/operations/auth.d.ts.map +1 -1
- package/dist/core/operations/auth.js +4 -0
- package/dist/core/operations/cart.d.ts +11 -10
- package/dist/core/operations/cart.d.ts.map +1 -1
- package/dist/core/operations/cart.js +14 -11
- package/dist/react/components/PaymentInstrumentSection.d.ts +24 -24
- package/dist/react/components/PaymentInstrumentSection.d.ts.map +1 -1
- package/dist/react/components/PaymentInstrumentSection.js +15 -15
- package/dist/react/components/PaymentInstrumentTile.d.ts +19 -20
- package/dist/react/components/PaymentInstrumentTile.d.ts.map +1 -1
- package/dist/react/components/PaymentInstrumentTile.js +15 -16
- package/dist/react/helpers/browser-data.d.ts +30 -33
- package/dist/react/helpers/browser-data.d.ts.map +1 -1
- package/dist/react/helpers/browser-data.js +26 -29
- package/dist/react/hooks/use-cart-manager.d.ts +1 -1
- package/dist/react/hooks/use-cart-manager.js +1 -1
- package/dist/react/hooks/use-cart.d.ts +2 -2
- package/dist/react/hooks/use-cart.js +3 -3
- package/dist/react/hooks/use-session-expired.d.ts +6 -5
- package/dist/react/hooks/use-session-expired.d.ts.map +1 -1
- package/dist/react/hooks/use-session-expired.js +6 -5
- package/dist/react/stores/auth.store.d.ts.map +1 -1
- package/dist/react/stores/auth.store.js +13 -10
- package/dist/react/stores/cart.store.d.ts +1 -1
- package/dist/react/stores/cart.store.js +1 -1
- 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
|
|
10
|
-
│
|
|
11
|
-
│
|
|
12
|
-
|
|
13
|
-
│
|
|
14
|
-
├── react
|
|
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
|
-
|
|
43
|
-
|
|
58
|
+
import {
|
|
59
|
+
createStorefrontAuthRoute,
|
|
60
|
+
trustedForwardedHostValidator,
|
|
61
|
+
} from '@doswiftly/storefront-sdk/react/server';
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
## Quick start — Core (framework-agnostic)
|
|
58
182
|
|
|
59
183
|
```typescript
|
|
60
184
|
import {
|
|
61
185
|
createStorefrontClient,
|
|
62
|
-
|
|
63
|
-
currencyMiddleware,
|
|
186
|
+
cartSecretMiddleware,
|
|
64
187
|
retryMiddleware,
|
|
65
188
|
timeoutMiddleware,
|
|
66
189
|
errorMiddleware,
|
|
67
190
|
CartClient,
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
217
|
+
const { cart: updated, warnings } = await cartClient.addItems(cart.id, [
|
|
218
|
+
{ variantId: 'variant-123', quantity: 1 },
|
|
219
|
+
]);
|
|
88
220
|
```
|
|
89
221
|
|
|
90
|
-
|
|
222
|
+
Queries are deduplicated and cacheable; mutations are never cached and never
|
|
223
|
+
retried.
|
|
91
224
|
|
|
92
|
-
|
|
93
|
-
// app/layout.tsx
|
|
94
|
-
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
|
|
225
|
+
## Export paths
|
|
95
226
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 {
|
|
263
|
+
import { useSessionExpired } from '@doswiftly/storefront-sdk/react';
|
|
112
264
|
|
|
113
|
-
|
|
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
|
-
|
|
268
|
+
### Sign-in form (BFF login)
|
|
121
269
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
329
|
+
`useAuth(options?)` is a convenience facade aggregating `useLogin`, `useLogout`
|
|
330
|
+
and `useRefreshToken`:
|
|
192
331
|
|
|
193
332
|
```ts
|
|
194
|
-
|
|
195
|
-
|
|
333
|
+
const {
|
|
334
|
+
login, logout, refreshToken,
|
|
335
|
+
isLoggingIn, isLoggingOut, isRefreshingToken, isLoading,
|
|
336
|
+
error,
|
|
337
|
+
} = useAuth();
|
|
196
338
|
```
|
|
197
339
|
|
|
198
|
-
**
|
|
340
|
+
Auth **state** (customer, flags) lives in the store, not in the hooks:
|
|
199
341
|
|
|
200
342
|
```ts
|
|
201
|
-
import {
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
httpOnly cookie after a hard refresh (`accessToken` no longer persists in
|
|
207
|
-
localStorage — XSS hardening):
|
|
350
|
+
### AuthClient (no React)
|
|
208
351
|
|
|
209
|
-
```
|
|
210
|
-
import {
|
|
352
|
+
```typescript
|
|
353
|
+
import { AuthClient } from '@doswiftly/storefront-sdk';
|
|
211
354
|
|
|
212
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
'use client';
|
|
223
|
-
import { useLogin } from '@doswiftly/storefront-sdk/react';
|
|
368
|
+
### Low-level route handlers (escape hatch)
|
|
224
369
|
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
'use client';
|
|
256
|
-
import { Image, PriceDisplay, AddToCartButton } from '@doswiftly/storefront-sdk/react';
|
|
387
|
+
### Behind a reverse proxy
|
|
257
388
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
##
|
|
419
|
+
## Cart
|
|
295
420
|
|
|
296
|
-
|
|
297
|
-
with your CSS approach. Available from `@doswiftly/storefront-sdk/react`:
|
|
421
|
+
### Capability model — cart id + secret
|
|
298
422
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
448
|
+
'use client';
|
|
449
|
+
import { useCartManager } from '@doswiftly/storefront-sdk/react';
|
|
310
450
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
471
|
+
**Per-operation recovery strategy** — when a write hits a stale cart
|
|
472
|
+
(`userErrors[].code` ∈ `CART_NOT_FOUND` / `ALREADY_COMPLETED`):
|
|
317
473
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
489
|
+
**Options** (`UseCartManagerOptions`) — all additive:
|
|
333
490
|
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
const {
|
|
343
|
-
const { refreshToken } = useRefreshToken({ onSetToken });
|
|
515
|
+
// CheckoutForm.tsx
|
|
516
|
+
const { addItem, complete, status } = useCartManagerContext();
|
|
344
517
|
```
|
|
345
518
|
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
570
|
+
// Shop-level payment methods + the default pre-selection signal.
|
|
571
|
+
const { methods, defaultMethod } = await cartClient.getAvailablePaymentMethods();
|
|
373
572
|
|
|
374
|
-
|
|
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
|
-
|
|
577
|
+
// Guest order lookup by the opaque token from complete().
|
|
578
|
+
const order = await cartClient.getOrderByToken(token, email);
|
|
579
|
+
```
|
|
377
580
|
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
###
|
|
590
|
+
### Auth ↔ cart lifecycle
|
|
397
591
|
|
|
398
592
|
```typescript
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
+
### Cart recovery without React
|
|
416
607
|
|
|
417
|
-
The recovery
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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` /
|
|
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
|
-
###
|
|
648
|
+
### `useCart(cartId)` — server-driven cart
|
|
469
649
|
|
|
470
|
-
`useCartManager`
|
|
650
|
+
Sister of `useCartManager` bound to an **explicit** `cartId` prop — never 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 {
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
const { addItem, updateItem, onExpired, isLoading } = useCartManager();
|
|
670
|
+
## Pre-built React components
|
|
480
671
|
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
694
|
+
## Middleware pipeline
|
|
495
695
|
|
|
496
|
-
|
|
696
|
+
Default order (wired automatically by `StorefrontProvider`):
|
|
497
697
|
|
|
498
|
-
|
|
698
|
+
```
|
|
699
|
+
auth → cart-secret → currency → language → bot-protection → [custom] → retry → timeout → errors (ALWAYS LAST)
|
|
700
|
+
```
|
|
499
701
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
+
## Core API
|
|
732
|
+
|
|
733
|
+
### createStorefrontClient
|
|
528
734
|
|
|
529
735
|
```typescript
|
|
530
|
-
const
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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; //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
formatPercentage(0.15); // "15%"
|
|
831
|
+
formatPercentage(0.15); // "15%"
|
|
580
832
|
```
|
|
581
833
|
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
594
|
-
const safe = sanitizeHtml(userHtml);
|
|
595
|
-
```
|
|
842
|
+
const safe = sanitizeHtml(userHtml); // strips <script>, event handlers, javascript: URLs
|
|
596
843
|
|
|
597
|
-
|
|
844
|
+
const { items, pageInfo, totalCount } = normalizeConnection(data.products); // Relay → flat array
|
|
845
|
+
```
|
|
598
846
|
|
|
599
|
-
|
|
600
|
-
import { normalizeConnection } from '@doswiftly/storefront-sdk';
|
|
847
|
+
### Cookie contracts (platform constants)
|
|
601
848
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
```
|
|
849
|
+
All first-party cookie names/defaults the platform relies on are exported —
|
|
850
|
+
never hardcode the strings:
|
|
605
851
|
|
|
606
|
-
|
|
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 {
|
|
862
|
+
import { parseCartCookieValue, formatCartCookieValue } from '@doswiftly/storefront-sdk';
|
|
610
863
|
|
|
611
|
-
//
|
|
612
|
-
//
|
|
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
|
-
###
|
|
869
|
+
### Route matching
|
|
616
870
|
|
|
617
871
|
```typescript
|
|
618
|
-
import {
|
|
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
|
-
|
|
625
|
-
export const POST = createClearTokenHandler();
|
|
874
|
+
matchesRoute('/account/orders', ['/account']); // true (exact + prefix matching)
|
|
626
875
|
```
|
|
627
876
|
|
|
628
|
-
|
|
629
|
-
Security: origin validation, Content-Type check, CSRF via SameSite=Lax, httpOnly cookie.
|
|
877
|
+
## React adapter
|
|
630
878
|
|
|
631
|
-
###
|
|
879
|
+
### `<StorefrontProvider>` props
|
|
632
880
|
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
644
|
-
|
|
899
|
+
`StorefrontClientProvider`, `CurrencyProvider`, `LanguageProvider` are exported
|
|
900
|
+
separately for custom composition.
|
|
645
901
|
|
|
646
|
-
|
|
647
|
-
matchesRoute('/account/orders', ['/account']); // true
|
|
648
|
-
matchesRoute('/products', ['/account']); // false
|
|
649
|
-
```
|
|
902
|
+
### Stores (Context-based)
|
|
650
903
|
|
|
651
|
-
|
|
904
|
+
All store hooks require the `StorefrontProvider` wrapper and accept an optional
|
|
905
|
+
selector:
|
|
652
906
|
|
|
653
907
|
```typescript
|
|
654
|
-
import {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
919
|
+
// Currency
|
|
920
|
+
const { currency, baseCurrency, supportedCurrencies, setCurrency, isLoaded } = useCurrencyStore();
|
|
665
921
|
|
|
666
|
-
|
|
922
|
+
// Language
|
|
923
|
+
const { language, setLanguage } = useLanguageStore();
|
|
667
924
|
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
929
|
+
Pre-built selectors: `selectCurrency`, `selectBaseCurrency`,
|
|
930
|
+
`selectSupportedCurrencies`, `selectIsLoaded`, `selectLanguage`,
|
|
931
|
+
`selectDefaultLanguage`, `selectSupportedLanguages`, `selectLanguageIsLoaded`.
|
|
676
932
|
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
941
|
+
Locale-aware formatters bound to the active storefront language:
|
|
693
942
|
|
|
694
943
|
```typescript
|
|
695
|
-
import {
|
|
944
|
+
import {
|
|
945
|
+
useFormatPrice, useFormatAmount, useFormatPriceRange,
|
|
946
|
+
useFormatDate, useFormatDateTime, useFormatNumber, useGetCurrencySymbol,
|
|
947
|
+
} from '@doswiftly/storefront-sdk/react';
|
|
696
948
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
705
|
-
<CartProvider store={store}>{children}</CartProvider>
|
|
953
|
+
### Generic hooks
|
|
706
954
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const cartId = useCartStore(s => s.cartId); // with selector
|
|
955
|
+
```typescript
|
|
956
|
+
import { useHydrated, useDebouncedValue, useStorefrontClient } from '@doswiftly/storefront-sdk/react';
|
|
710
957
|
|
|
711
|
-
//
|
|
712
|
-
|
|
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
|
-
|
|
716
|
-
Template provides: CartActions DI implementation (GraphQL hooks, React Query, fetch — any transport).
|
|
963
|
+
### `createStoreContext`
|
|
717
964
|
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
975
|
+
### Bot protection
|
|
732
976
|
|
|
733
|
-
|
|
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
|
-
|
|
736
|
-
import { useHydrated } from '@doswiftly/storefront-sdk/react';
|
|
984
|
+
## Server-side (`/react/server`)
|
|
737
985
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
//
|
|
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
|
-
|
|
1000
|
+
SSR cart read with the capability secret:
|
|
744
1001
|
|
|
745
1002
|
```typescript
|
|
746
|
-
|
|
1003
|
+
// Server Component / Route Handler
|
|
1004
|
+
const credentials = await readCartCredentials();
|
|
747
1005
|
|
|
748
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1044
|
+
## GraphQL schema for codegen
|
|
794
1045
|
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
+
## Deprecated
|
|
808
1065
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
|