@doswiftly/storefront-sdk 4.4.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/core/cart/types.d.ts +75 -20
  2. package/dist/core/cart/types.d.ts.map +1 -1
  3. package/dist/core/cart/types.js +3 -0
  4. package/dist/core/image.d.ts +24 -2
  5. package/dist/core/image.d.ts.map +1 -1
  6. package/dist/core/image.js +145 -2
  7. package/dist/core/index.d.ts +1 -1
  8. package/dist/core/index.d.ts.map +1 -1
  9. package/dist/core/index.js +2 -0
  10. package/dist/core/operations/cart.d.ts +15 -9
  11. package/dist/core/operations/cart.d.ts.map +1 -1
  12. package/dist/core/operations/cart.js +131 -58
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +1 -1
  15. package/package.json +19 -14
  16. package/src/__tests__/contract/storefront-api.contract.test.ts +0 -450
  17. package/src/__tests__/unit/auth-client.test.ts +0 -210
  18. package/src/__tests__/unit/bot-protection.test.ts +0 -461
  19. package/src/__tests__/unit/cart-client.test.ts +0 -233
  20. package/src/__tests__/unit/cart-store.test.ts +0 -349
  21. package/src/__tests__/unit/create-client.test.ts +0 -356
  22. package/src/__tests__/unit/helpers.test.ts +0 -377
  23. package/src/__tests__/unit/middleware.test.ts +0 -374
  24. package/src/__tests__/unit/test-helpers.ts +0 -103
  25. package/src/core/auth/auth-client.ts +0 -123
  26. package/src/core/auth/cookie-config.ts +0 -23
  27. package/src/core/auth/handlers.ts +0 -168
  28. package/src/core/auth/routes.ts +0 -26
  29. package/src/core/auth/token-client.ts +0 -51
  30. package/src/core/auth/types.ts +0 -54
  31. package/src/core/bot-protection/abstract-manager.ts +0 -185
  32. package/src/core/bot-protection/create-manager.ts +0 -37
  33. package/src/core/bot-protection/eucaptcha-manager.ts +0 -88
  34. package/src/core/bot-protection/fallback-manager.ts +0 -43
  35. package/src/core/bot-protection/turnstile-manager.ts +0 -92
  36. package/src/core/bot-protection/types/eucaptcha.d.ts +0 -28
  37. package/src/core/bot-protection/types/turnstile.d.ts +0 -33
  38. package/src/core/cache.ts +0 -102
  39. package/src/core/cart/cart-client.ts +0 -150
  40. package/src/core/cart/cookie-config.ts +0 -13
  41. package/src/core/cart/types.ts +0 -104
  42. package/src/core/client/compose.ts +0 -15
  43. package/src/core/client/create-client.ts +0 -129
  44. package/src/core/client/dedupe.ts +0 -19
  45. package/src/core/client/execute.ts +0 -70
  46. package/src/core/client/hash.ts +0 -21
  47. package/src/core/client/operation-name.ts +0 -12
  48. package/src/core/client/types.ts +0 -171
  49. package/src/core/currency/cookie-config.ts +0 -13
  50. package/src/core/errors.ts +0 -67
  51. package/src/core/format.ts +0 -254
  52. package/src/core/helpers/assert-no-user-errors.ts +0 -21
  53. package/src/core/helpers/normalize-connection.ts +0 -48
  54. package/src/core/helpers/sanitize-html.ts +0 -42
  55. package/src/core/image.ts +0 -22
  56. package/src/core/index.ts +0 -174
  57. package/src/core/language/cookie-config.ts +0 -13
  58. package/src/core/middleware/auth.ts +0 -27
  59. package/src/core/middleware/bot-protection.ts +0 -140
  60. package/src/core/middleware/currency.ts +0 -27
  61. package/src/core/middleware/errors.ts +0 -86
  62. package/src/core/middleware/language.ts +0 -30
  63. package/src/core/middleware/retry.ts +0 -75
  64. package/src/core/middleware/timeout.ts +0 -61
  65. package/src/core/operations/auth.ts +0 -123
  66. package/src/core/operations/cart.ts +0 -185
  67. package/src/index.ts +0 -25
  68. package/src/react/bot-protection/bot-protection-context.ts +0 -17
  69. package/src/react/bot-protection/bot-protection-widget.tsx +0 -46
  70. package/src/react/cookies.ts +0 -89
  71. package/src/react/helpers/create-store-context.ts +0 -56
  72. package/src/react/hooks/use-auth.ts +0 -218
  73. package/src/react/hooks/use-bot-protection.ts +0 -31
  74. package/src/react/hooks/use-cart-manager.ts +0 -236
  75. package/src/react/hooks/use-currency.ts +0 -23
  76. package/src/react/hooks/use-debounced-value.ts +0 -30
  77. package/src/react/hooks/use-hydrated.ts +0 -20
  78. package/src/react/hooks/use-storefront-client.ts +0 -12
  79. package/src/react/index.ts +0 -71
  80. package/src/react/providers/currency-provider.tsx +0 -30
  81. package/src/react/providers/language-provider.tsx +0 -34
  82. package/src/react/providers/storefront-client-provider.tsx +0 -107
  83. package/src/react/providers/storefront-provider.tsx +0 -99
  84. package/src/react/server/get-storefront-client.ts +0 -60
  85. package/src/react/server/index.ts +0 -1
  86. package/src/react/stores/auth.store.ts +0 -112
  87. package/src/react/stores/cart.context.ts +0 -10
  88. package/src/react/stores/cart.store.ts +0 -254
  89. package/src/react/stores/currency.store.ts +0 -93
  90. package/src/react/stores/index.ts +0 -17
  91. package/src/react/stores/language.store.ts +0 -90
  92. package/src/react/stores/store-context.tsx +0 -103
  93. package/src/react/types/shop-config.ts +0 -22
  94. package/tsconfig.json +0 -20
  95. package/vitest.config.ts +0 -14
@@ -1,34 +0,0 @@
1
- /**
2
- * LanguageProvider — initializes language store from Shop data.
3
- *
4
- * Thin wrapper that calls useLanguageStore.initialize() on mount
5
- * with the shop's language configuration from the server.
6
- *
7
- * NOTE: This only sets defaultLanguage and supportedLanguages.
8
- * The active `language` is set by the template's LanguageSyncProvider
9
- * which reads locale from the URL (via next-intl) and calls setLanguage().
10
- */
11
-
12
- 'use client';
13
-
14
- import React, { useEffect } from 'react';
15
- import { useLanguageStore } from '../stores/store-context';
16
- import type { ShopConfig } from '../types/shop-config';
17
-
18
- export interface LanguageProviderProps {
19
- children: React.ReactNode;
20
- shopData: ShopConfig;
21
- }
22
-
23
- export function LanguageProvider({ children, shopData }: LanguageProviderProps) {
24
- const initialize = useLanguageStore((s) => s.initialize);
25
- const isLoaded = useLanguageStore((s) => s.isLoaded);
26
-
27
- useEffect(() => {
28
- if (!isLoaded) {
29
- initialize(shopData);
30
- }
31
- }, [initialize, isLoaded, shopData]);
32
-
33
- return <>{children}</>;
34
- }
@@ -1,107 +0,0 @@
1
- /**
2
- * StorefrontClientProvider — React context for SDK clients.
3
- *
4
- * Creates StorefrontClient + CartClient + AuthClient and provides them
5
- * to the component tree via React context.
6
- *
7
- * Registers default middleware pipeline: auth → currency → retry → timeout → errors.
8
- */
9
-
10
- 'use client';
11
-
12
- import React, { createContext, useContext, useMemo } from 'react';
13
- import { createStorefrontClient } from '../../core/client/create-client';
14
- import { CartClient } from '../../core/cart/cart-client';
15
- import { AuthClient } from '../../core/auth/auth-client';
16
- import { authMiddleware } from '../../core/middleware/auth';
17
- import { currencyMiddleware } from '../../core/middleware/currency';
18
- import { languageMiddleware } from '../../core/middleware/language';
19
- import { botProtectionMiddleware, type BotProtectionTokenProvider } from '../../core/middleware/bot-protection';
20
- import { retryMiddleware } from '../../core/middleware/retry';
21
- import { timeoutMiddleware } from '../../core/middleware/timeout';
22
- import { errorMiddleware } from '../../core/middleware/errors';
23
- import { useAuthStoreApi, useCurrencyStoreApi, useLanguageStoreApi } from '../stores/store-context';
24
- import type { StorefrontClient, StorefrontClientConfig, Middleware } from '../../core/client/types';
25
-
26
- export interface StorefrontClientContextValue {
27
- client: StorefrontClient;
28
- cartClient: CartClient;
29
- authClient: AuthClient;
30
- }
31
-
32
- const StorefrontClientContext = createContext<StorefrontClientContextValue | null>(null);
33
-
34
- export interface StorefrontClientProviderProps {
35
- children: React.ReactNode;
36
- config: StorefrontClientConfig;
37
- /**
38
- * Additional middleware to prepend to the default pipeline.
39
- * Default pipeline: auth → currency → bot-protection → [custom] → retry → timeout → errors
40
- */
41
- middleware?: Middleware[];
42
- /** Bot protection token provider (created by StorefrontProvider) */
43
- botProtection?: BotProtectionTokenProvider | null;
44
- /** Operations that require bot protection (from shop query) */
45
- botProtectionOperations?: string[];
46
- }
47
-
48
- export function StorefrontClientProvider({
49
- children,
50
- config,
51
- middleware: customMiddleware = [],
52
- botProtection,
53
- botProtectionOperations,
54
- }: StorefrontClientProviderProps) {
55
- const authStore = useAuthStoreApi();
56
- const currencyStore = useCurrencyStoreApi();
57
- const languageStore = useLanguageStoreApi();
58
-
59
- const value = useMemo(() => {
60
- const client = createStorefrontClient({
61
- ...config,
62
- middleware: [
63
- // Header middleware (runs first)
64
- authMiddleware(() => authStore.getState().accessToken),
65
- currencyMiddleware(() => currencyStore.getState().currency),
66
- languageMiddleware(() => languageStore.getState().language),
67
- // Bot protection (if configured)
68
- ...(botProtection && botProtectionOperations?.length
69
- ? [botProtectionMiddleware({
70
- tokenProvider: botProtection,
71
- protectedOperations: botProtectionOperations,
72
- })]
73
- : []),
74
- // Custom middleware from props
75
- ...customMiddleware,
76
- // Infrastructure middleware (runs last)
77
- retryMiddleware({ maxRetries: 2 }),
78
- timeoutMiddleware({ timeout: 5000 }),
79
- errorMiddleware(),
80
- ],
81
- });
82
-
83
- const cartClient = new CartClient(client);
84
- const authClient = new AuthClient(client);
85
-
86
- return { client, cartClient, authClient };
87
- // eslint-disable-next-line react-hooks/exhaustive-deps
88
- }, [config.apiUrl, config.shopSlug, authStore, currencyStore, languageStore]);
89
-
90
- return (
91
- <StorefrontClientContext.Provider value={value}>
92
- {children}
93
- </StorefrontClientContext.Provider>
94
- );
95
- }
96
-
97
- /**
98
- * Get StorefrontClient context value.
99
- * Must be used within StorefrontClientProvider.
100
- */
101
- export function useStorefrontClientContext(): StorefrontClientContextValue {
102
- const ctx = useContext(StorefrontClientContext);
103
- if (!ctx) {
104
- throw new Error('useStorefrontClientContext must be used within StorefrontClientProvider');
105
- }
106
- return ctx;
107
- }
@@ -1,99 +0,0 @@
1
- /**
2
- * StorefrontProvider — convenience composition of all SDK providers.
3
- *
4
- * Creates store instances via useRef (stable across re-renders) and provides
5
- * them through React Context. Store Contexts wrap StorefrontClientProvider
6
- * so that middleware can access store state via useAuthStoreApi/useCurrencyStoreApi.
7
- *
8
- * Composition order: Store Contexts → StorefrontClientProvider → CurrencyProvider
9
- *
10
- * @example
11
- * ```tsx
12
- * // app/layout.tsx
13
- * import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
14
- *
15
- * export default async function RootLayout({ children }) {
16
- * const shopData = await fetchShop();
17
- * return (
18
- * <StorefrontProvider
19
- * config={{ apiUrl: '...', shopSlug: '...' }}
20
- * shopData={shopData}
21
- * >
22
- * {children}
23
- * </StorefrontProvider>
24
- * );
25
- * }
26
- * ```
27
- */
28
-
29
- 'use client';
30
-
31
- import React, { useRef } from 'react';
32
- import { AuthStoreContext, CurrencyStoreContext, LanguageStoreContext } from '../stores/store-context';
33
- import { createAuthStore } from '../stores/auth.store';
34
- import { createCurrencyStore } from '../stores/currency.store';
35
- import { createLanguageStore } from '../stores/language.store';
36
- import { StorefrontClientProvider, type StorefrontClientProviderProps } from './storefront-client-provider';
37
- import { CurrencyProvider, type CurrencyProviderProps } from './currency-provider';
38
- import { LanguageProvider } from './language-provider';
39
- import { createBotProtectionManager } from '../../core/bot-protection/create-manager';
40
- import { BotProtectionContext } from '../bot-protection/bot-protection-context';
41
- import { BotProtectionWidget } from '../bot-protection/bot-protection-widget';
42
-
43
- export interface StorefrontProviderProps extends StorefrontClientProviderProps {
44
- shopData: CurrencyProviderProps['shopData'];
45
- /**
46
- * Server-side auth hint — set to `true` when httpOnly auth cookie exists.
47
- * This allows the auth store to start with `isAuthenticated: true` on first render,
48
- * eliminating the flash of "Sign In" while Zustand persist rehydrates from localStorage.
49
- *
50
- * Read from cookies() in a Server Component (layout.tsx) and pass here.
51
- */
52
- initialIsAuthenticated?: boolean;
53
- /**
54
- * Server-side language hint — pass the URL locale from next-intl params.
55
- * Eliminates flash of wrong language on first render by initializing the
56
- * language store with the correct value from the server.
57
- */
58
- initialLanguage?: string;
59
- }
60
-
61
- export function StorefrontProvider({
62
- children,
63
- config,
64
- middleware,
65
- shopData,
66
- initialIsAuthenticated,
67
- initialLanguage,
68
- }: StorefrontProviderProps) {
69
- const authStoreRef = useRef(createAuthStore(initialIsAuthenticated));
70
- const currencyStoreRef = useRef(createCurrencyStore());
71
- const languageStoreRef = useRef(createLanguageStore(initialLanguage));
72
- const botProtectionRef = useRef(
73
- shopData.botProtection ? createBotProtectionManager(shopData.botProtection) : null,
74
- );
75
-
76
- return (
77
- <AuthStoreContext.Provider value={authStoreRef.current}>
78
- <CurrencyStoreContext.Provider value={currencyStoreRef.current}>
79
- <LanguageStoreContext.Provider value={languageStoreRef.current}>
80
- <StorefrontClientProvider
81
- config={config}
82
- middleware={middleware}
83
- botProtection={botProtectionRef.current}
84
- botProtectionOperations={shopData.botProtection?.protectedOperations}
85
- >
86
- <BotProtectionContext.Provider value={{ manager: botProtectionRef.current }}>
87
- <CurrencyProvider shopData={shopData}>
88
- <LanguageProvider shopData={shopData}>
89
- <BotProtectionWidget manager={botProtectionRef.current} />
90
- {children}
91
- </LanguageProvider>
92
- </CurrencyProvider>
93
- </BotProtectionContext.Provider>
94
- </StorefrontClientProvider>
95
- </LanguageStoreContext.Provider>
96
- </CurrencyStoreContext.Provider>
97
- </AuthStoreContext.Provider>
98
- );
99
- }
@@ -1,60 +0,0 @@
1
- /**
2
- * Server-side StorefrontClient factory — for Server Components, API routes, etc.
3
- *
4
- * Creates a StorefrontClient with default middleware pipeline,
5
- * without requiring React context (no providers needed server-side).
6
- *
7
- * @example
8
- * ```typescript
9
- * // lib/graphql/server.ts
10
- * import { getStorefrontClient } from '@doswiftly/storefront-sdk/react/server';
11
- *
12
- * const client = getStorefrontClient({
13
- * apiUrl: process.env.NEXT_PUBLIC_API_URL!,
14
- * shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
15
- * });
16
- *
17
- * export const fetchProduct = cache(async (handle: string) => {
18
- * return client.query(ProductDocument, { handle });
19
- * });
20
- * ```
21
- */
22
-
23
- import { createStorefrontClient } from '../../core/client/create-client';
24
- import { retryMiddleware } from '../../core/middleware/retry';
25
- import { timeoutMiddleware } from '../../core/middleware/timeout';
26
- import { errorMiddleware } from '../../core/middleware/errors';
27
- import type { StorefrontClient, StorefrontClientConfig, Middleware } from '../../core/client/types';
28
-
29
- export interface ServerClientOptions extends Omit<StorefrontClientConfig, 'middleware'> {
30
- /**
31
- * Function that returns request-scoped headers (e.g. currency from cookie).
32
- * Called once per client creation.
33
- */
34
- getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
35
- /**
36
- * Additional middleware (prepended to default pipeline).
37
- */
38
- middleware?: Middleware[];
39
- }
40
-
41
- /**
42
- * Create a StorefrontClient for server-side use.
43
- *
44
- * Includes default middleware: retry → timeout → errors.
45
- * Does NOT include auth/currency middleware (server has no Zustand stores).
46
- * Pass headers via config.defaultHeaders or getHeaders option.
47
- */
48
- export function getStorefrontClient(options: ServerClientOptions): StorefrontClient {
49
- const { middleware: customMiddleware = [], ...config } = options;
50
-
51
- return createStorefrontClient({
52
- ...config,
53
- middleware: [
54
- ...customMiddleware,
55
- retryMiddleware({ maxRetries: 2 }),
56
- timeoutMiddleware({ timeout: 10000 }), // Server-side: 10s timeout
57
- errorMiddleware(),
58
- ],
59
- });
60
- }
@@ -1 +0,0 @@
1
- export { getStorefrontClient, type ServerClientOptions } from './get-storefront-client';
@@ -1,112 +0,0 @@
1
- /**
2
- * Auth store factory — localStorage persistence via Zustand persist.
3
- *
4
- * Creates a vanilla Zustand store (not a React hook singleton).
5
- * Instance is created in StorefrontProvider via useRef and provided
6
- * through React Context — eliminates module duplication in Turbopack.
7
- *
8
- * httpOnly cookie is managed separately (for SSR/middleware).
9
- */
10
-
11
- import { createStore } from 'zustand/vanilla';
12
- import { persist } from 'zustand/middleware';
13
-
14
- export interface CustomerInfo {
15
- id: string;
16
- email: string;
17
- firstName?: string;
18
- lastName?: string;
19
- phone?: string;
20
- }
21
-
22
- export interface AuthStore {
23
- // State
24
- customer: CustomerInfo | null;
25
- accessToken: string | null;
26
- isAuthenticated: boolean;
27
- isLoading: boolean;
28
-
29
- // Actions
30
- setAuth: (customer: CustomerInfo, accessToken: string) => void;
31
- clearAuth: () => void;
32
- updateCustomer: (updates: Partial<CustomerInfo>) => void;
33
- setLoading: (isLoading: boolean) => void;
34
- }
35
-
36
- export const createAuthStore = (initialIsAuthenticated = false) =>
37
- createStore<AuthStore>()(
38
- persist(
39
- (set) => ({
40
- customer: null,
41
- accessToken: null,
42
- isAuthenticated: initialIsAuthenticated,
43
- isLoading: false,
44
-
45
- setAuth: (customer, accessToken) =>
46
- set({
47
- customer,
48
- accessToken,
49
- isAuthenticated: true,
50
- }),
51
-
52
- clearAuth: () =>
53
- set({
54
- customer: null,
55
- accessToken: null,
56
- isAuthenticated: false,
57
- }),
58
-
59
- updateCustomer: (updates) =>
60
- set((state) => ({
61
- customer: state.customer
62
- ? { ...state.customer, ...updates }
63
- : null,
64
- })),
65
-
66
- setLoading: (isLoading) => set({ isLoading }),
67
- }),
68
- {
69
- name: 'auth-storage',
70
- version: 2, // Invalidate stale data from old module-level store era
71
- partialize: (state) => ({
72
- customer: state.customer,
73
- accessToken: state.accessToken,
74
- isAuthenticated: state.isAuthenticated,
75
- }),
76
- /**
77
- * Custom merge — server cookie check (initialIsAuthenticated) is authoritative
78
- * for isAuthenticated. localStorage provides supplementary data (customer, token).
79
- *
80
- * This prevents stale localStorage from overwriting the server-provided auth state.
81
- */
82
- merge: (persistedState, currentState) => {
83
- const persisted = persistedState as Partial<AuthStore> | undefined;
84
- if (!persisted) return currentState;
85
-
86
- return {
87
- ...currentState,
88
- customer: persisted.customer ?? currentState.customer,
89
- accessToken: persisted.accessToken ?? currentState.accessToken,
90
- // Server cookie is the authority — never let stale localStorage override it
91
- isAuthenticated: currentState.isAuthenticated,
92
- } as AuthStore;
93
- },
94
- migrate: (persistedState, version) => {
95
- if (version < 2) {
96
- // Data from old module-level store may be unreliable (Turbopack duplication)
97
- // Clear it — user will re-authenticate via cookie or login
98
- return { customer: null, accessToken: null, isAuthenticated: false } as {
99
- customer: CustomerInfo | null;
100
- accessToken: string | null;
101
- isAuthenticated: boolean;
102
- };
103
- }
104
- return persistedState as {
105
- customer: CustomerInfo | null;
106
- accessToken: string | null;
107
- isAuthenticated: boolean;
108
- };
109
- },
110
- },
111
- ),
112
- );
@@ -1,10 +0,0 @@
1
- 'use client';
2
-
3
- import { createStoreContext } from '../helpers/create-store-context';
4
- import type { CartState } from './cart.store';
5
-
6
- export const {
7
- Provider: CartProvider,
8
- useStore: useCartStore,
9
- useApi: useCartStoreApi,
10
- } = createStoreContext<CartState>('CartStore');
@@ -1,254 +0,0 @@
1
- /**
2
- * Cart Store — DI-based cart state management with cookie persistence.
3
- *
4
- * SDK orchestrates cart lifecycle (init, mutations, error handling).
5
- * Template provides CartActions implementation via DI (getActions getter).
6
- * Cart ID persisted in cookie (SSR/edge visible) — follows currency store pattern.
7
- *
8
- * @example
9
- * ```typescript
10
- * import { createCartStore, type CartActions } from '@doswiftly/storefront-sdk/react';
11
- *
12
- * const actions: CartActions = {
13
- * fetchCart: async (cartId) => api.getCart(cartId),
14
- * createCart: async () => api.createCart().then(c => c.id),
15
- * addLines: async (cartId, lines) => api.addLines(cartId, lines),
16
- * updateLines: async (cartId, lines) => api.updateLines(cartId, lines),
17
- * removeLines: async (cartId, lineIds) => api.removeLines(cartId, lineIds),
18
- * };
19
- *
20
- * const store = createCartStore({ getActions: () => actions });
21
- * ```
22
- */
23
-
24
- import { createStore } from 'zustand/vanilla';
25
- import type { CartLineInput, CartLineUpdateInput } from '../../core/cart/types';
26
- import { CART_COOKIE_NAME, CART_COOKIE_MAX_AGE } from '../../core/cart/cookie-config';
27
- import { getCookie, setCookie, deleteCookie } from '../cookies';
28
-
29
- // Re-export input types from core for convenience
30
- export type { CartLineInput, CartLineUpdateInput } from '../../core/cart/types';
31
-
32
- // ---------------------------------------------------------------------------
33
- // DI Contract
34
- // ---------------------------------------------------------------------------
35
-
36
- /** Minimal cart data returned by DI actions. */
37
- export interface CartData {
38
- id: string;
39
- totalQuantity: number;
40
- }
41
-
42
- /** DI contract — template implements these methods. */
43
- export interface CartActions {
44
- /** Fetch cart by ID. Return null if cart expired/not found. */
45
- fetchCart: (cartId: string) => Promise<CartData | null>;
46
- /** Create new empty cart. Return new cart ID. */
47
- createCart: () => Promise<string>;
48
- /** Add line items. Return updated cart or throw. */
49
- addLines: (cartId: string, lines: CartLineInput[]) => Promise<CartData>;
50
- /** Update line quantities. Return updated cart or throw. */
51
- updateLines: (cartId: string, lines: CartLineUpdateInput[]) => Promise<CartData>;
52
- /** Remove line items. Return updated cart or throw. */
53
- removeLines: (cartId: string, lineIds: string[]) => Promise<CartData>;
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Config
58
- // ---------------------------------------------------------------------------
59
-
60
- /** Action names passed to mutation callbacks. */
61
- export type CartMutationAction = 'initCart' | 'addToCart' | 'updateQuantity' | 'removeFromCart';
62
-
63
- export interface CartStoreConfig {
64
- /** Getter for DI actions — called on every operation (not captured). */
65
- getActions: () => CartActions;
66
- /** Called after successful mutation. */
67
- onMutationSuccess?: (action: CartMutationAction, cart: CartData) => void;
68
- /** Called on mutation error. */
69
- onMutationError?: (action: CartMutationAction, error: unknown) => void;
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // State
74
- // ---------------------------------------------------------------------------
75
-
76
- export interface CartState {
77
- // State
78
- cartId: string | null;
79
- isOpen: boolean;
80
- isLoading: boolean;
81
- error: unknown | null;
82
-
83
- // UI Actions (pure state, no side effects)
84
- openCart: () => void;
85
- closeCart: () => void;
86
- toggleCart: () => void;
87
-
88
- // Orchestrated Actions (call DI via getActions)
89
- initCart: () => Promise<void>;
90
- addToCart: (lines: CartLineInput[]) => Promise<void>;
91
- updateQuantity: (lines: CartLineUpdateInput[]) => Promise<void>;
92
- removeFromCart: (lineIds: string[]) => Promise<void>;
93
- clearCart: () => void;
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Selectors
98
- // ---------------------------------------------------------------------------
99
-
100
- export const selectCartId = (state: CartState) => state.cartId;
101
- export const selectIsCartOpen = (state: CartState) => state.isOpen;
102
- export const selectCartIsLoading = (state: CartState) => state.isLoading;
103
-
104
- // ---------------------------------------------------------------------------
105
- // Factory
106
- // ---------------------------------------------------------------------------
107
-
108
- export function createCartStore(config: CartStoreConfig) {
109
- // Deduplication: shared Promise in closure (not in state)
110
- let initPromise: Promise<void> | null = null;
111
-
112
- // Read initial cartId from cookie (client-side only, returns null on server)
113
- const initialCartId = getCookie(CART_COOKIE_NAME);
114
-
115
- // Clean up old localStorage entry from pre-cookie era (v1 persist)
116
- if (typeof localStorage !== 'undefined') {
117
- try { localStorage.removeItem('cart-storage'); } catch { /* ignore */ }
118
- }
119
-
120
- async function performInit(
121
- set: (partial: Partial<CartState> | ((state: CartState) => Partial<CartState>)) => void,
122
- get: () => CartState,
123
- ): Promise<void> {
124
- const actions = config.getActions();
125
- set({ isLoading: true, error: null });
126
-
127
- try {
128
- const currentCartId = get().cartId;
129
-
130
- if (currentCartId) {
131
- const cart = await actions.fetchCart(currentCartId);
132
- if (cart) {
133
- // Cart exists — keep current cartId
134
- set({ isLoading: false });
135
- return;
136
- }
137
- // Cart expired — fall through to create
138
- }
139
-
140
- // No cart or expired — create new
141
- const newCartId = await actions.createCart();
142
- set({ cartId: newCartId, isLoading: false, error: null });
143
- } catch (error) {
144
- set({ error, isLoading: false });
145
- config.onMutationError?.('initCart', error);
146
- }
147
- }
148
-
149
- const store = createStore<CartState>()(
150
- (set, get) => ({
151
- // State — initialCartId from cookie
152
- cartId: initialCartId,
153
- isOpen: false,
154
- isLoading: false,
155
- error: null,
156
-
157
- // UI Actions
158
- openCart: () => set({ isOpen: true }),
159
- closeCart: () => set({ isOpen: false }),
160
- toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
161
-
162
- // Orchestrated: initCart (deduplicated)
163
- initCart: async () => {
164
- if (initPromise) return initPromise;
165
- initPromise = performInit(set, get).finally(() => {
166
- initPromise = null;
167
- });
168
- return initPromise;
169
- },
170
-
171
- // Orchestrated: addToCart (auto-init if no cartId)
172
- addToCart: async (lines: CartLineInput[]) => {
173
- const actions = config.getActions();
174
- set({ isLoading: true, error: null });
175
-
176
- try {
177
- let cartId = get().cartId;
178
- if (!cartId) {
179
- await get().initCart();
180
- cartId = get().cartId;
181
- }
182
- if (!cartId) {
183
- throw new Error('Failed to initialize cart');
184
- }
185
-
186
- const cart = await actions.addLines(cartId, lines);
187
- set({ isLoading: false, error: null });
188
- config.onMutationSuccess?.('addToCart', cart);
189
- } catch (error) {
190
- set({ error, isLoading: false });
191
- config.onMutationError?.('addToCart', error);
192
- }
193
- },
194
-
195
- // Orchestrated: updateQuantity (error if no cartId)
196
- updateQuantity: async (lines: CartLineUpdateInput[]) => {
197
- const actions = config.getActions();
198
- const cartId = get().cartId;
199
-
200
- if (!cartId) {
201
- const err = new Error('Cannot update quantity: no cart exists');
202
- set({ error: err });
203
- config.onMutationError?.('updateQuantity', err);
204
- return;
205
- }
206
-
207
- set({ isLoading: true, error: null });
208
- try {
209
- const cart = await actions.updateLines(cartId, lines);
210
- set({ isLoading: false, error: null });
211
- config.onMutationSuccess?.('updateQuantity', cart);
212
- } catch (error) {
213
- set({ error, isLoading: false });
214
- config.onMutationError?.('updateQuantity', error);
215
- }
216
- },
217
-
218
- // Orchestrated: removeFromCart (silent return if no cartId)
219
- removeFromCart: async (lineIds: string[]) => {
220
- const cartId = get().cartId;
221
- if (!cartId) return;
222
-
223
- const actions = config.getActions();
224
- set({ isLoading: true, error: null });
225
- try {
226
- const cart = await actions.removeLines(cartId, lineIds);
227
- set({ isLoading: false, error: null });
228
- config.onMutationSuccess?.('removeFromCart', cart);
229
- } catch (error) {
230
- set({ error, isLoading: false });
231
- config.onMutationError?.('removeFromCart', error);
232
- }
233
- },
234
-
235
- // clearCart — reset all state
236
- clearCart: () => {
237
- set({ cartId: null, isOpen: false, isLoading: false, error: null });
238
- },
239
- }),
240
- );
241
-
242
- // Sync cartId changes to cookie (like currency store pattern)
243
- store.subscribe((state, prevState) => {
244
- if (state.cartId !== prevState.cartId) {
245
- if (state.cartId) {
246
- setCookie(CART_COOKIE_NAME, state.cartId, { maxAge: CART_COOKIE_MAX_AGE });
247
- } else {
248
- deleteCookie(CART_COOKIE_NAME);
249
- }
250
- }
251
- });
252
-
253
- return store;
254
- }