@doswiftly/storefront-sdk 11.1.0 → 11.3.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 +131 -0
- package/README.md +297 -2
- package/dist/core/auth/handlers.d.ts.map +1 -1
- package/dist/core/auth/handlers.js +29 -1
- package/dist/core/bot-protection/turnstile-manager.d.ts +0 -1
- package/dist/core/bot-protection/turnstile-manager.d.ts.map +1 -1
- package/dist/core/bot-protection/turnstile-manager.js +0 -1
- package/dist/core/cart/cart-recovery.d.ts +210 -0
- package/dist/core/cart/cart-recovery.d.ts.map +1 -0
- package/dist/core/cart/cart-recovery.js +271 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/react/components/AddToCartButton.d.ts +49 -0
- package/dist/react/components/AddToCartButton.d.ts.map +1 -0
- package/dist/react/components/AddToCartButton.js +47 -0
- package/dist/react/components/CartCount.d.ts +35 -0
- package/dist/react/components/CartCount.d.ts.map +1 -0
- package/dist/react/components/CartCount.js +23 -0
- package/dist/react/components/CartTotals.d.ts +54 -0
- package/dist/react/components/CartTotals.d.ts.map +1 -0
- package/dist/react/components/CartTotals.js +38 -0
- package/dist/react/components/Image.d.ts +42 -0
- package/dist/react/components/Image.d.ts.map +1 -0
- package/dist/react/components/Image.js +33 -0
- package/dist/react/components/Money.d.ts +32 -0
- package/dist/react/components/Money.d.ts.map +1 -0
- package/dist/react/components/Money.js +27 -0
- package/dist/react/components/PriceDisplay.d.ts +34 -0
- package/dist/react/components/PriceDisplay.d.ts.map +1 -0
- package/dist/react/components/PriceDisplay.js +21 -0
- package/dist/react/components/index.d.ts +15 -0
- package/dist/react/components/index.d.ts.map +1 -0
- package/dist/react/components/index.js +14 -0
- package/dist/react/cookies.d.ts +21 -0
- package/dist/react/cookies.d.ts.map +1 -1
- package/dist/react/cookies.js +29 -1
- package/dist/react/hooks/use-auth.d.ts +19 -46
- package/dist/react/hooks/use-auth.d.ts.map +1 -1
- package/dist/react/hooks/use-auth.js +24 -141
- package/dist/react/hooks/use-cart-manager.d.ts +75 -15
- package/dist/react/hooks/use-cart-manager.d.ts.map +1 -1
- package/dist/react/hooks/use-cart-manager.js +106 -194
- package/dist/react/hooks/use-login.d.ts +40 -0
- package/dist/react/hooks/use-login.d.ts.map +1 -0
- package/dist/react/hooks/use-login.js +75 -0
- package/dist/react/hooks/use-logout.d.ts +40 -0
- package/dist/react/hooks/use-logout.d.ts.map +1 -0
- package/dist/react/hooks/use-logout.js +50 -0
- package/dist/react/hooks/use-refresh-token.d.ts +40 -0
- package/dist/react/hooks/use-refresh-token.d.ts.map +1 -0
- package/dist/react/hooks/use-refresh-token.js +66 -0
- package/dist/react/index.d.ts +6 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -1
- package/dist/react/server/get-storefront-client.d.ts +15 -5
- package/dist/react/server/get-storefront-client.d.ts.map +1 -1
- package/dist/react/stores/cart.store.d.ts +57 -10
- package/dist/react/stores/cart.store.d.ts.map +1 -1
- package/dist/react/stores/cart.store.js +112 -21
- package/dist/react/stores/store-context.d.ts.map +1 -1
- package/dist/react/stores/store-context.js +0 -2
- package/package.json +11 -4
- package/dist/__tests__/unit/test-helpers.d.ts +0 -46
- package/dist/__tests__/unit/test-helpers.d.ts.map +0 -1
- package/dist/__tests__/unit/test-helpers.js +0 -72
|
@@ -1,153 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useAuth —
|
|
2
|
+
* useAuth — convenience facade composing `useLogin`, `useLogout`, and
|
|
3
|
+
* `useRefreshToken`. Preserves the pre-11.2 API for backward compatibility.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - Zustand store (for client-side state)
|
|
5
|
+
* **Prefer the focused hooks** in new code — they tree-shake better, isolate
|
|
6
|
+
* state, and keep dependency arrays smaller:
|
|
7
7
|
*
|
|
8
|
-
* Does NOT use React Query — plain async + store updates.
|
|
9
|
-
* Template can wrap in useMutation() if React Query features are needed.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
8
|
* ```tsx
|
|
13
|
-
*
|
|
9
|
+
* // Smaller bundle, single-responsibility:
|
|
10
|
+
* const { login, isLoggingIn } = useLogin({ onSetToken });
|
|
11
|
+
* const { logout } = useLogout({ onClearToken });
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* // Convenience aggregator (legacy / mixed flows):
|
|
14
|
+
* const { login, logout, refreshToken, isLoading } = useAuth({ onSetToken, onClearToken });
|
|
17
15
|
* ```
|
|
16
|
+
*
|
|
17
|
+
* Centralizes dual-layer persistence:
|
|
18
|
+
* - httpOnly cookie (for SSR/middleware) — via `onSetToken`/`onClearToken`
|
|
19
|
+
* callbacks that hit your BFF route handlers
|
|
20
|
+
* - Zustand auth store (for client-side state).
|
|
18
21
|
*/
|
|
19
22
|
'use client';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { StorefrontError } from '../../core/errors';
|
|
23
|
+
import { useLogin } from './use-login';
|
|
24
|
+
import { useLogout } from './use-logout';
|
|
25
|
+
import { useRefreshToken } from './use-refresh-token';
|
|
24
26
|
export function useAuth(options = {}) {
|
|
25
|
-
const {
|
|
26
|
-
const {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const login = useCallback(async (email, password) => {
|
|
33
|
-
setError(null);
|
|
34
|
-
setIsLoggingIn(true);
|
|
35
|
-
try {
|
|
36
|
-
const result = await authClient.login(email, password);
|
|
37
|
-
// Set httpOnly cookie
|
|
38
|
-
if (options.onSetToken) {
|
|
39
|
-
await options.onSetToken(result.accessToken);
|
|
40
|
-
}
|
|
41
|
-
// Fetch customer data and set store
|
|
42
|
-
try {
|
|
43
|
-
const customer = await authClient.getCustomer();
|
|
44
|
-
if (customer) {
|
|
45
|
-
setAuth({
|
|
46
|
-
id: customer.id,
|
|
47
|
-
email: customer.email,
|
|
48
|
-
firstName: customer.firstName ?? undefined,
|
|
49
|
-
lastName: customer.lastName ?? undefined,
|
|
50
|
-
phone: customer.phone ?? undefined,
|
|
51
|
-
}, result.accessToken);
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
setAuth({ id: '', email }, result.accessToken);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// Customer fetch failed — store minimal data
|
|
59
|
-
setAuth({ id: '', email }, result.accessToken);
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
success: true,
|
|
63
|
-
userErrors: [],
|
|
64
|
-
accessToken: result.accessToken,
|
|
65
|
-
expiresAt: result.expiresAt,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
if (err instanceof StorefrontError && err.hasUserErrors) {
|
|
70
|
-
return {
|
|
71
|
-
success: false,
|
|
72
|
-
userErrors: err.userErrors.map((e) => ({
|
|
73
|
-
message: e.message,
|
|
74
|
-
field: e.field,
|
|
75
|
-
})),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
const message = err instanceof Error ? err.message : 'Login failed';
|
|
79
|
-
setError(message);
|
|
80
|
-
return { success: false, userErrors: [{ message }] };
|
|
81
|
-
}
|
|
82
|
-
finally {
|
|
83
|
-
setIsLoggingIn(false);
|
|
84
|
-
}
|
|
85
|
-
}, [authClient, setAuth, options]);
|
|
86
|
-
const logout = useCallback(async () => {
|
|
87
|
-
setError(null);
|
|
88
|
-
setIsLoggingOut(true);
|
|
89
|
-
try {
|
|
90
|
-
// Auth context resolved server-side from cookie/Bearer — no token arg needed
|
|
91
|
-
await authClient.logout();
|
|
92
|
-
// Clear httpOnly cookie
|
|
93
|
-
if (options.onClearToken) {
|
|
94
|
-
await options.onClearToken();
|
|
95
|
-
}
|
|
96
|
-
clearAuth();
|
|
97
|
-
return { success: true, userErrors: [] };
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
// Even on error, clear local state
|
|
101
|
-
clearAuth();
|
|
102
|
-
const message = err instanceof Error ? err.message : 'Logout failed';
|
|
103
|
-
setError(message);
|
|
104
|
-
return { success: false, userErrors: [{ message }] };
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
setIsLoggingOut(false);
|
|
108
|
-
}
|
|
109
|
-
}, [authClient, clearAuth, options, authStore]);
|
|
110
|
-
const refreshToken = useCallback(async () => {
|
|
111
|
-
setError(null);
|
|
112
|
-
setIsRenewingToken(true);
|
|
113
|
-
try {
|
|
114
|
-
// Auth context resolved server-side from cookie/Bearer — no token arg needed.
|
|
115
|
-
// If no active session, backend will return 401 and refreshToken throws.
|
|
116
|
-
const result = await authClient.refreshToken();
|
|
117
|
-
// Update httpOnly cookie
|
|
118
|
-
if (options.onSetToken) {
|
|
119
|
-
await options.onSetToken(result.accessToken);
|
|
120
|
-
}
|
|
121
|
-
// Update store (keep customer data, update token)
|
|
122
|
-
const currentCustomer = authStore.getState().customer;
|
|
123
|
-
if (currentCustomer) {
|
|
124
|
-
setAuth(currentCustomer, result.accessToken);
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
success: true,
|
|
128
|
-
userErrors: [],
|
|
129
|
-
accessToken: result.accessToken,
|
|
130
|
-
expiresAt: result.expiresAt,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
if (err instanceof StorefrontError && err.hasUserErrors) {
|
|
135
|
-
return {
|
|
136
|
-
success: false,
|
|
137
|
-
userErrors: err.userErrors.map((e) => ({
|
|
138
|
-
message: e.message,
|
|
139
|
-
field: e.field,
|
|
140
|
-
})),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
const message = err instanceof Error ? err.message : 'Token renewal failed';
|
|
144
|
-
setError(message);
|
|
145
|
-
return { success: false, userErrors: [{ message }] };
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
setIsRenewingToken(false);
|
|
149
|
-
}
|
|
150
|
-
}, [authClient, setAuth, options, authStore]);
|
|
27
|
+
const { login, isLoggingIn, error: loginError } = useLogin(options);
|
|
28
|
+
const { logout, isLoggingOut, error: logoutError } = useLogout(options);
|
|
29
|
+
const { refreshToken, isRefreshingToken, error: refreshError } = useRefreshToken(options);
|
|
30
|
+
// Aggregate the most-recent error across the three flows. Each focused hook
|
|
31
|
+
// tracks its own error independently, but legacy consumers expect a single
|
|
32
|
+
// `error` field — surface whichever is non-null (priority: login > logout > refresh).
|
|
33
|
+
const error = loginError ?? logoutError ?? refreshError;
|
|
151
34
|
return {
|
|
152
35
|
login,
|
|
153
36
|
logout,
|
|
@@ -1,31 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCartManager —
|
|
2
|
+
* useCartManager — DX-first cart hook with automatic stale-cart recovery.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Wraps `CartClient` with cookie persistence + per-operation recovery strategy.
|
|
5
|
+
* Built on top of `createCartRecoveryRunner` from the core layer, so the same
|
|
6
|
+
* recovery semantics work for non-React consumers (Vue/Svelte/CLI/mobile).
|
|
7
|
+
*
|
|
8
|
+
* Per-operation strategy:
|
|
9
|
+
*
|
|
10
|
+
* | Operation | Strategy | Why |
|
|
11
|
+
* | ----------------------- | ------------------------------------- | --------------------------------------------- |
|
|
12
|
+
* | `addItem` | Auto-replay (atomic `cartCreate`) | Storefront expects "add to cart" always works |
|
|
13
|
+
* | `updateBuyerIdentity` | Auto-replay | User just typed email/phone — keep it |
|
|
14
|
+
* | `setShippingAddress` | Auto-replay | User just typed address — keep it |
|
|
15
|
+
* | `updateDiscountCodes` | Auto-replay | Coupon valid independently of cart |
|
|
16
|
+
* | `updateNote` | Auto-replay | Stateless / idempotent |
|
|
17
|
+
* | `updateItem` | Bail + `cart-expired` event | `lineId` refers to a line in the dead cart |
|
|
18
|
+
* | `removeItem` | Bail + `cart-expired` event | jw. |
|
|
19
|
+
*
|
|
20
|
+
* On bail the runner clears the cookie and calls every `onExpired` listener
|
|
21
|
+
* with a `CartExpiredEvent`. UI subscribes once globally and shows a toast /
|
|
22
|
+
* banner — caller code never writes `try / catch` per mutation.
|
|
23
|
+
*
|
|
24
|
+
* Auto-creates cart on first add. Cart id persisted in `cart-id` cookie
|
|
25
|
+
* (SSR/edge visible, 30 days, samesite=lax).
|
|
7
26
|
*
|
|
8
27
|
* @example
|
|
9
28
|
* ```tsx
|
|
10
|
-
*
|
|
29
|
+
* function CartUI() {
|
|
30
|
+
* const { addItem, onExpired } = useCartManager();
|
|
31
|
+
*
|
|
32
|
+
* useEffect(() => onExpired(() => toast('Koszyk wygasł, dodaj produkty ponownie')), [onExpired]);
|
|
11
33
|
*
|
|
12
|
-
*
|
|
34
|
+
* return <button onClick={() => addItem([{ variantId, quantity: 1 }])}>Add</button>;
|
|
35
|
+
* }
|
|
13
36
|
* ```
|
|
14
37
|
*/
|
|
15
|
-
import type { Cart, CartLineInput, CartLineUpdateInput } from '../../core/cart/types';
|
|
38
|
+
import type { Cart, CartLineInput, CartLineUpdateInput, CartBuyerIdentityInput, CartAddressInput } from '../../core/cart/types';
|
|
16
39
|
import type { CartMutationOutcome } from '../../core/cart/cart-client';
|
|
17
|
-
|
|
40
|
+
import { type CartExpiredEvent } from '../../core/cart/cart-recovery';
|
|
41
|
+
/**
|
|
42
|
+
* Names of mutations exposed by the hook — narrows `status.operation` for
|
|
43
|
+
* exhaustive consumer-side switching (e.g. operation-specific spinners).
|
|
44
|
+
*/
|
|
45
|
+
export type CartManagerOperation = 'addItem' | 'updateItem' | 'removeItem' | 'updateBuyerIdentity' | 'setShippingAddress' | 'updateDiscountCodes' | 'updateNote';
|
|
46
|
+
/**
|
|
47
|
+
* Tagged union of cart mutation lifecycle states. Lets callers do exhaustive
|
|
48
|
+
* switching without remembering which boolean flag pairs with which:
|
|
49
|
+
*
|
|
50
|
+
* ```tsx
|
|
51
|
+
* const { status } = useCartManager();
|
|
52
|
+
* if (status.type === 'loading') return <Spinner label={status.operation} />;
|
|
53
|
+
* if (status.type === 'error') return <ErrorBanner error={status.error} />;
|
|
54
|
+
* // narrowed here: status.type ∈ { 'idle', 'success' }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* `idle` is the initial state and the state after `clearCart()`.
|
|
58
|
+
*/
|
|
59
|
+
export type CartManagerStatus = {
|
|
60
|
+
type: 'idle';
|
|
61
|
+
} | {
|
|
62
|
+
type: 'loading';
|
|
63
|
+
operation: CartManagerOperation;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'error';
|
|
66
|
+
operation: CartManagerOperation;
|
|
67
|
+
error: Error;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'success';
|
|
70
|
+
operation: CartManagerOperation;
|
|
71
|
+
};
|
|
72
|
+
export interface UseCartManagerResult {
|
|
18
73
|
getCart: () => Promise<Cart | null>;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
removeItem: (lineIds: string[]) => Promise<CartMutationOutcome>;
|
|
74
|
+
getCartId: () => string | null;
|
|
75
|
+
addItem: (lines: CartLineInput[]) => Promise<CartMutationOutcome>;
|
|
76
|
+
updateBuyerIdentity: (buyerIdentity: CartBuyerIdentityInput) => Promise<CartMutationOutcome>;
|
|
77
|
+
setShippingAddress: (address: CartAddressInput) => Promise<CartMutationOutcome>;
|
|
24
78
|
updateDiscountCodes: (codes: string[]) => Promise<CartMutationOutcome>;
|
|
25
79
|
updateNote: (note: string) => Promise<CartMutationOutcome>;
|
|
80
|
+
updateItem: (lines: CartLineUpdateInput[]) => Promise<CartMutationOutcome>;
|
|
81
|
+
removeItem: (lineIds: string[]) => Promise<CartMutationOutcome>;
|
|
26
82
|
clearCart: () => void;
|
|
27
|
-
|
|
83
|
+
onExpired: (listener: (event: CartExpiredEvent) => void) => () => void;
|
|
84
|
+
status: CartManagerStatus;
|
|
85
|
+
/** `true` when status.type === 'loading'. */
|
|
28
86
|
isLoading: boolean;
|
|
87
|
+
/** Error message when status.type === 'error', otherwise null. */
|
|
29
88
|
error: string | null;
|
|
30
|
-
}
|
|
89
|
+
}
|
|
90
|
+
export declare function useCartManager(): UseCartManagerResult;
|
|
31
91
|
//# sourceMappingURL=use-cart-manager.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-cart-manager.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/use-cart-manager.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"use-cart-manager.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/use-cart-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAMH,OAAO,KAAK,EACV,IAAI,EACJ,aAAa,EACb,mBAAmB,EACnB,sBAAsB,EACtB,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,+BAA+B,CAAC;AAGvC;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAC5B,SAAS,GACT,YAAY,GACZ,YAAY,GACZ,qBAAqB,GACrB,oBAAoB,GACpB,qBAAqB,GACrB,YAAY,CAAC;AAEjB;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,oBAAoB,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,oBAAoB,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,oBAAoB,CAAA;CAAE,CAAC;AAEzD,MAAM,WAAW,oBAAoB;IAEnC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;IACpC,SAAS,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAG/B,OAAO,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAClE,mBAAmB,EAAE,CAAC,aAAa,EAAE,sBAAsB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC7F,kBAAkB,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAChF,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACvE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAG3D,UAAU,EAAE,CAAC,KAAK,EAAE,mBAAmB,EAAE,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC3E,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAGhE,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAGvE,MAAM,EAAE,iBAAiB,CAAC;IAG1B,6CAA6C;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,kEAAkE;IAClE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAgB,cAAc,IAAI,oBAAoB,CA0LrD"}
|
|
@@ -1,221 +1,133 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCartManager —
|
|
2
|
+
* useCartManager — DX-first cart hook with automatic stale-cart recovery.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Wraps `CartClient` with cookie persistence + per-operation recovery strategy.
|
|
5
|
+
* Built on top of `createCartRecoveryRunner` from the core layer, so the same
|
|
6
|
+
* recovery semantics work for non-React consumers (Vue/Svelte/CLI/mobile).
|
|
7
|
+
*
|
|
8
|
+
* Per-operation strategy:
|
|
9
|
+
*
|
|
10
|
+
* | Operation | Strategy | Why |
|
|
11
|
+
* | ----------------------- | ------------------------------------- | --------------------------------------------- |
|
|
12
|
+
* | `addItem` | Auto-replay (atomic `cartCreate`) | Storefront expects "add to cart" always works |
|
|
13
|
+
* | `updateBuyerIdentity` | Auto-replay | User just typed email/phone — keep it |
|
|
14
|
+
* | `setShippingAddress` | Auto-replay | User just typed address — keep it |
|
|
15
|
+
* | `updateDiscountCodes` | Auto-replay | Coupon valid independently of cart |
|
|
16
|
+
* | `updateNote` | Auto-replay | Stateless / idempotent |
|
|
17
|
+
* | `updateItem` | Bail + `cart-expired` event | `lineId` refers to a line in the dead cart |
|
|
18
|
+
* | `removeItem` | Bail + `cart-expired` event | jw. |
|
|
19
|
+
*
|
|
20
|
+
* On bail the runner clears the cookie and calls every `onExpired` listener
|
|
21
|
+
* with a `CartExpiredEvent`. UI subscribes once globally and shows a toast /
|
|
22
|
+
* banner — caller code never writes `try / catch` per mutation.
|
|
23
|
+
*
|
|
24
|
+
* Auto-creates cart on first add. Cart id persisted in `cart-id` cookie
|
|
25
|
+
* (SSR/edge visible, 30 days, samesite=lax).
|
|
7
26
|
*
|
|
8
27
|
* @example
|
|
9
28
|
* ```tsx
|
|
10
|
-
*
|
|
29
|
+
* function CartUI() {
|
|
30
|
+
* const { addItem, onExpired } = useCartManager();
|
|
11
31
|
*
|
|
12
|
-
*
|
|
32
|
+
* useEffect(() => onExpired(() => toast('Koszyk wygasł, dodaj produkty ponownie')), [onExpired]);
|
|
33
|
+
*
|
|
34
|
+
* return <button onClick={() => addItem([{ variantId, quantity: 1 }])}>Add</button>;
|
|
35
|
+
* }
|
|
13
36
|
* ```
|
|
14
37
|
*/
|
|
15
38
|
'use client';
|
|
16
|
-
import {
|
|
39
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
17
40
|
import { useStorefrontClientContext } from '../providers/storefront-client-provider';
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
// Cart ID persistence (cookie — SSR/edge visible)
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
function getCartIdFromCookie() {
|
|
23
|
-
if (typeof document === 'undefined')
|
|
24
|
-
return null;
|
|
25
|
-
const match = document.cookie.match(/(?:^|;\s*)cart-id=([^;]*)/);
|
|
26
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
27
|
-
}
|
|
28
|
-
function setCartIdCookie(cartId) {
|
|
29
|
-
if (typeof document === 'undefined')
|
|
30
|
-
return;
|
|
31
|
-
const maxAge = 30 * 24 * 60 * 60; // 30 days
|
|
32
|
-
document.cookie = `cart-id=${encodeURIComponent(cartId)};max-age=${maxAge};path=/;samesite=lax`;
|
|
33
|
-
}
|
|
34
|
-
function clearCartIdCookie() {
|
|
35
|
-
if (typeof document === 'undefined')
|
|
36
|
-
return;
|
|
37
|
-
document.cookie = 'cart-id=;max-age=0;path=/';
|
|
38
|
-
}
|
|
41
|
+
import { createCartRecoveryRunner, recreateWithInput, } from '../../core/cart/cart-recovery';
|
|
42
|
+
import { createBrowserCartCookieStore } from '../cookies';
|
|
39
43
|
export function useCartManager() {
|
|
40
44
|
const { cartClient } = useStorefrontClientContext();
|
|
41
|
-
const [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* Check if error indicates cart not found (expired).
|
|
58
|
-
*/
|
|
59
|
-
const isCartExpired = (err) => {
|
|
60
|
-
if (err instanceof StorefrontError) {
|
|
61
|
-
return err.message.toLowerCase().includes('cart not found') ||
|
|
62
|
-
err.message.toLowerCase().includes('cart does not exist');
|
|
63
|
-
}
|
|
64
|
-
return false;
|
|
65
|
-
};
|
|
66
|
-
/**
|
|
67
|
-
* Fetch current cart. Returns null if no cart exists.
|
|
68
|
-
*/
|
|
69
|
-
const getCart = useCallback(async () => {
|
|
70
|
-
const cartId = getCartIdFromCookie();
|
|
71
|
-
if (!cartId)
|
|
72
|
-
return null;
|
|
73
|
-
try {
|
|
74
|
-
return await cartClient.get(cartId);
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
if (isCartExpired(err)) {
|
|
78
|
-
clearCartIdCookie();
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
throw err;
|
|
82
|
-
}
|
|
83
|
-
}, [cartClient]);
|
|
84
|
-
/**
|
|
85
|
-
* Add items to cart. Creates cart if needed.
|
|
86
|
-
* On expired cart, clears cookie, creates new cart, retries once.
|
|
87
|
-
*/
|
|
88
|
-
const addItem = useCallback(async (lines, options) => {
|
|
89
|
-
setError(null);
|
|
90
|
-
setIsLoading(true);
|
|
91
|
-
try {
|
|
92
|
-
const cartId = await getOrCreateCartId(options?.forceNewCart);
|
|
93
|
-
return await cartClient.addItems(cartId, lines);
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
if (isCartExpired(err) && !options?.forceNewCart) {
|
|
97
|
-
clearCartIdCookie();
|
|
98
|
-
return addItem(lines, { forceNewCart: true });
|
|
99
|
-
}
|
|
100
|
-
const message = err instanceof Error ? err.message : 'Failed to add to cart';
|
|
101
|
-
setError(message);
|
|
102
|
-
throw err;
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
setIsLoading(false);
|
|
106
|
-
}
|
|
107
|
-
}, [cartClient, getOrCreateCartId]);
|
|
108
|
-
/**
|
|
109
|
-
* Update line items (quantity, attributes).
|
|
110
|
-
*/
|
|
111
|
-
const updateItem = useCallback(async (lines) => {
|
|
112
|
-
setError(null);
|
|
113
|
-
setIsLoading(true);
|
|
114
|
-
try {
|
|
115
|
-
const cartId = getCartIdFromCookie();
|
|
116
|
-
if (!cartId)
|
|
117
|
-
throw new Error('No cart found');
|
|
118
|
-
return await cartClient.updateItems(cartId, lines);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
if (isCartExpired(err)) {
|
|
122
|
-
clearCartIdCookie();
|
|
123
|
-
}
|
|
124
|
-
const message = err instanceof Error ? err.message : 'Failed to update cart';
|
|
125
|
-
setError(message);
|
|
126
|
-
throw err;
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
|
-
setIsLoading(false);
|
|
130
|
-
}
|
|
131
|
-
}, [cartClient]);
|
|
132
|
-
/**
|
|
133
|
-
* Remove items by line IDs.
|
|
134
|
-
*/
|
|
135
|
-
const removeItem = useCallback(async (lineIds) => {
|
|
136
|
-
setError(null);
|
|
137
|
-
setIsLoading(true);
|
|
45
|
+
const [status, setStatus] = useState({ type: 'idle' });
|
|
46
|
+
// Cookie store is stateless — keep one instance per hook mount.
|
|
47
|
+
const cookieStore = useMemo(() => createBrowserCartCookieStore(), []);
|
|
48
|
+
// Recovery runner is bound to cartClient identity (changes only on provider
|
|
49
|
+
// re-mount). Sharing one runner per hook gives concurrent operations a single
|
|
50
|
+
// recovery coordinator (Phase 0 mutex).
|
|
51
|
+
const runner = useMemo(() => createCartRecoveryRunner({ cartClient, cookieStore }), [cartClient, cookieStore]);
|
|
52
|
+
// Subscribe to cart-expired events. The returned unsubscribe function is
|
|
53
|
+
// bound to the current runner identity — wrap calls in
|
|
54
|
+
// `useEffect(() => onExpired(...), [onExpired])` so React re-subscribes when
|
|
55
|
+
// the provider (and thus the runner) remounts.
|
|
56
|
+
const onExpired = useCallback((listener) => runner.onExpired(listener), [runner]);
|
|
57
|
+
// Generic mutation wrapper — drives the tagged-union status. Delegates
|
|
58
|
+
// recovery semantics to the runner (which fires onExpired separately).
|
|
59
|
+
const wrapMutation = useCallback(async (operation, run, failureFallbackMessage) => {
|
|
60
|
+
setStatus({ type: 'loading', operation });
|
|
138
61
|
try {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return await cartClient.removeItems(cartId, lineIds);
|
|
62
|
+
const result = await run();
|
|
63
|
+
setStatus({ type: 'success', operation });
|
|
64
|
+
return result;
|
|
143
65
|
}
|
|
144
66
|
catch (err) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
const message = err instanceof Error ? err.message : 'Failed to remove from cart';
|
|
149
|
-
setError(message);
|
|
67
|
+
const error = err instanceof Error ? err : new Error(failureFallbackMessage);
|
|
68
|
+
setStatus({ type: 'error', operation, error });
|
|
150
69
|
throw err;
|
|
151
70
|
}
|
|
152
|
-
finally {
|
|
153
|
-
setIsLoading(false);
|
|
154
|
-
}
|
|
155
|
-
}, [cartClient]);
|
|
156
|
-
/**
|
|
157
|
-
* Update discount codes (replaces all existing).
|
|
158
|
-
*/
|
|
159
|
-
const updateDiscountCodes = useCallback(async (codes) => {
|
|
160
|
-
setError(null);
|
|
161
|
-
setIsLoading(true);
|
|
162
|
-
try {
|
|
163
|
-
const cartId = getCartIdFromCookie();
|
|
164
|
-
if (!cartId)
|
|
165
|
-
throw new Error('No cart found');
|
|
166
|
-
return await cartClient.updateDiscountCodes(cartId, codes);
|
|
167
|
-
}
|
|
168
|
-
catch (err) {
|
|
169
|
-
const message = err instanceof Error ? err.message : 'Failed to update discount codes';
|
|
170
|
-
setError(message);
|
|
171
|
-
throw err;
|
|
172
|
-
}
|
|
173
|
-
finally {
|
|
174
|
-
setIsLoading(false);
|
|
175
|
-
}
|
|
176
|
-
}, [cartClient]);
|
|
177
|
-
/**
|
|
178
|
-
* Update cart note.
|
|
179
|
-
*/
|
|
180
|
-
const updateNote = useCallback(async (note) => {
|
|
181
|
-
setError(null);
|
|
182
|
-
setIsLoading(true);
|
|
183
|
-
try {
|
|
184
|
-
const cartId = getCartIdFromCookie();
|
|
185
|
-
if (!cartId)
|
|
186
|
-
throw new Error('No cart found');
|
|
187
|
-
return await cartClient.updateNote(cartId, note);
|
|
188
|
-
}
|
|
189
|
-
catch (err) {
|
|
190
|
-
const message = err instanceof Error ? err.message : 'Failed to update note';
|
|
191
|
-
setError(message);
|
|
192
|
-
throw err;
|
|
193
|
-
}
|
|
194
|
-
finally {
|
|
195
|
-
setIsLoading(false);
|
|
196
|
-
}
|
|
197
|
-
}, [cartClient]);
|
|
198
|
-
/**
|
|
199
|
-
* Clear cart — removes cookie.
|
|
200
|
-
*/
|
|
201
|
-
const clearCart = useCallback(() => {
|
|
202
|
-
clearCartIdCookie();
|
|
203
|
-
}, []);
|
|
204
|
-
/**
|
|
205
|
-
* Get current cart ID from cookie (if exists).
|
|
206
|
-
*/
|
|
207
|
-
const getCartId = useCallback(() => {
|
|
208
|
-
return getCartIdFromCookie();
|
|
209
71
|
}, []);
|
|
72
|
+
// --- Read ---
|
|
73
|
+
const getCart = useCallback(() => runner.getCart(), [runner]);
|
|
74
|
+
const getCartId = useCallback(() => cookieStore.get(), [cookieStore]);
|
|
75
|
+
// --- Auto-replay mutations ---
|
|
76
|
+
const addItem = useCallback((lines) => wrapMutation('addItem', () => runner.execute({
|
|
77
|
+
name: 'addItem',
|
|
78
|
+
run: (cartId) => cartClient.addItems(cartId, lines),
|
|
79
|
+
recreateAndRun: recreateWithInput({ lines }),
|
|
80
|
+
}), 'Failed to add to cart'), [runner, cartClient, wrapMutation]);
|
|
81
|
+
const updateBuyerIdentity = useCallback((buyerIdentity) => wrapMutation('updateBuyerIdentity', () => runner.execute({
|
|
82
|
+
name: 'updateBuyerIdentity',
|
|
83
|
+
run: (cartId) => cartClient.updateBuyerIdentity(cartId, buyerIdentity),
|
|
84
|
+
recreateAndRun: recreateWithInput({ buyerIdentity }),
|
|
85
|
+
}), 'Failed to update buyer identity'), [runner, cartClient, wrapMutation]);
|
|
86
|
+
const setShippingAddress = useCallback((address) => wrapMutation('setShippingAddress', () => runner.execute({
|
|
87
|
+
name: 'setShippingAddress',
|
|
88
|
+
run: (cartId) => cartClient.setShippingAddress({ cartId, address }),
|
|
89
|
+
recreateAndRun: recreateWithInput({ shippingAddress: address }),
|
|
90
|
+
}), 'Failed to set shipping address'), [runner, cartClient, wrapMutation]);
|
|
91
|
+
const updateDiscountCodes = useCallback((codes) => wrapMutation('updateDiscountCodes', () => runner.execute({
|
|
92
|
+
name: 'updateDiscountCodes',
|
|
93
|
+
run: (cartId) => cartClient.updateDiscountCodes(cartId, codes),
|
|
94
|
+
recreateAndRun: recreateWithInput({ discountCodes: codes }),
|
|
95
|
+
}), 'Failed to update discount codes'), [runner, cartClient, wrapMutation]);
|
|
96
|
+
const updateNote = useCallback((note) => wrapMutation('updateNote', () => runner.execute({
|
|
97
|
+
name: 'updateNote',
|
|
98
|
+
run: (cartId) => cartClient.updateNote(cartId, note),
|
|
99
|
+
recreateAndRun: recreateWithInput({ note }),
|
|
100
|
+
}), 'Failed to update note'), [runner, cartClient, wrapMutation]);
|
|
101
|
+
// --- Bail-on-stale mutations (no recreateAndRun — fires onExpired + throws) ---
|
|
102
|
+
const updateItem = useCallback((lines) => wrapMutation('updateItem', () => runner.execute({
|
|
103
|
+
name: 'updateItem',
|
|
104
|
+
run: (cartId) => cartClient.updateItems(cartId, lines),
|
|
105
|
+
}), 'Failed to update cart'), [runner, cartClient, wrapMutation]);
|
|
106
|
+
const removeItem = useCallback((lineIds) => wrapMutation('removeItem', () => runner.execute({
|
|
107
|
+
name: 'removeItem',
|
|
108
|
+
run: (cartId) => cartClient.removeItems(cartId, lineIds),
|
|
109
|
+
}), 'Failed to remove from cart'), [runner, cartClient, wrapMutation]);
|
|
110
|
+
// --- Lifecycle ---
|
|
111
|
+
const clearCart = useCallback(() => {
|
|
112
|
+
cookieStore.clear();
|
|
113
|
+
setStatus({ type: 'idle' });
|
|
114
|
+
}, [cookieStore]);
|
|
115
|
+
// Backward-compatible derived selectors over the tagged-union status.
|
|
116
|
+
const isLoading = status.type === 'loading';
|
|
117
|
+
const error = status.type === 'error' ? status.error.message : null;
|
|
210
118
|
return {
|
|
211
119
|
getCart,
|
|
120
|
+
getCartId,
|
|
212
121
|
addItem,
|
|
213
|
-
|
|
214
|
-
|
|
122
|
+
updateBuyerIdentity,
|
|
123
|
+
setShippingAddress,
|
|
215
124
|
updateDiscountCodes,
|
|
216
125
|
updateNote,
|
|
126
|
+
updateItem,
|
|
127
|
+
removeItem,
|
|
217
128
|
clearCart,
|
|
218
|
-
|
|
129
|
+
onExpired,
|
|
130
|
+
status,
|
|
219
131
|
isLoading,
|
|
220
132
|
error,
|
|
221
133
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLogin — focused hook for the login flow.
|
|
3
|
+
*
|
|
4
|
+
* Composes the auth client + Zustand store + httpOnly cookie BFF callback.
|
|
5
|
+
* Returns `success`/`userErrors` rather than throwing on credential errors —
|
|
6
|
+
* standard form submission pattern.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const { login, isLoggingIn, error } = useLogin({ onSetToken });
|
|
11
|
+
* const result = await login(email, password);
|
|
12
|
+
* if (!result.success) showErrors(result.userErrors);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export interface UseLoginOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Called after successful login with the new access token. Use this to set
|
|
18
|
+
* the httpOnly cookie via an API route handler (`createSetTokenHandler`).
|
|
19
|
+
*/
|
|
20
|
+
onSetToken?: (token: string) => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface LoginResult {
|
|
23
|
+
success: boolean;
|
|
24
|
+
userErrors: Array<{
|
|
25
|
+
message: string;
|
|
26
|
+
field?: string[];
|
|
27
|
+
}>;
|
|
28
|
+
accessToken?: string;
|
|
29
|
+
expiresAt?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface UseLoginReturn {
|
|
32
|
+
/** Submit the login form. Resolves with `{success, userErrors}` rather than throwing on credential failures. */
|
|
33
|
+
login: (email: string, password: string) => Promise<LoginResult>;
|
|
34
|
+
/** `true` while a login request is in flight. */
|
|
35
|
+
isLoggingIn: boolean;
|
|
36
|
+
/** Last unexpected error message (network, server) — credential failures land in `userErrors` instead. */
|
|
37
|
+
error: string | null;
|
|
38
|
+
}
|
|
39
|
+
export declare function useLogin(options?: UseLoginOptions): UseLoginReturn;
|
|
40
|
+
//# sourceMappingURL=use-login.d.ts.map
|