@doswiftly/storefront-sdk 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +51 -9
  2. package/dist/core/bot-protection/abstract-manager.d.ts +57 -0
  3. package/dist/core/bot-protection/abstract-manager.d.ts.map +1 -0
  4. package/dist/core/bot-protection/abstract-manager.js +144 -0
  5. package/dist/core/bot-protection/create-manager.d.ts +15 -0
  6. package/dist/core/bot-protection/create-manager.d.ts.map +1 -0
  7. package/dist/core/bot-protection/create-manager.js +33 -0
  8. package/dist/core/bot-protection/eucaptcha-manager.d.ts +15 -0
  9. package/dist/core/bot-protection/eucaptcha-manager.d.ts.map +1 -0
  10. package/dist/core/bot-protection/eucaptcha-manager.js +76 -0
  11. package/dist/core/bot-protection/fallback-manager.d.ts +18 -0
  12. package/dist/core/bot-protection/fallback-manager.d.ts.map +1 -0
  13. package/dist/core/bot-protection/fallback-manager.js +42 -0
  14. package/dist/core/bot-protection/turnstile-manager.d.ts +15 -0
  15. package/dist/core/bot-protection/turnstile-manager.d.ts.map +1 -0
  16. package/dist/core/bot-protection/turnstile-manager.js +78 -0
  17. package/dist/core/cart/cookie-config.d.ts +14 -0
  18. package/dist/core/cart/cookie-config.d.ts.map +1 -0
  19. package/dist/core/cart/cookie-config.js +13 -0
  20. package/dist/core/currency/cookie-config.d.ts +14 -0
  21. package/dist/core/currency/cookie-config.d.ts.map +1 -0
  22. package/dist/core/currency/cookie-config.js +13 -0
  23. package/dist/core/image.d.ts +55 -0
  24. package/dist/core/image.d.ts.map +1 -0
  25. package/dist/core/image.js +48 -0
  26. package/dist/core/index.d.ts +8 -0
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +13 -0
  29. package/dist/core/language/cookie-config.d.ts +14 -0
  30. package/dist/core/language/cookie-config.d.ts.map +1 -0
  31. package/dist/core/language/cookie-config.js +13 -0
  32. package/dist/core/middleware/bot-protection.d.ts +71 -0
  33. package/dist/core/middleware/bot-protection.d.ts.map +1 -0
  34. package/dist/core/middleware/bot-protection.js +63 -0
  35. package/dist/core/middleware/currency.d.ts.map +1 -1
  36. package/dist/core/middleware/currency.js +2 -1
  37. package/dist/core/middleware/language.d.ts +18 -0
  38. package/dist/core/middleware/language.d.ts.map +1 -0
  39. package/dist/core/middleware/language.js +25 -0
  40. package/dist/react/bot-protection/bot-protection-context.d.ts +12 -0
  41. package/dist/react/bot-protection/bot-protection-context.d.ts.map +1 -0
  42. package/dist/react/bot-protection/bot-protection-context.js +9 -0
  43. package/dist/react/bot-protection/bot-protection-widget.d.ts +13 -0
  44. package/dist/react/bot-protection/bot-protection-widget.d.ts.map +1 -0
  45. package/dist/react/bot-protection/bot-protection-widget.js +34 -0
  46. package/dist/react/cookies.d.ts +17 -0
  47. package/dist/react/cookies.d.ts.map +1 -1
  48. package/dist/react/cookies.js +36 -3
  49. package/dist/react/hooks/use-bot-protection.d.ts +16 -0
  50. package/dist/react/hooks/use-bot-protection.d.ts.map +1 -0
  51. package/dist/react/hooks/use-bot-protection.js +24 -0
  52. package/dist/react/index.d.ts +10 -1
  53. package/dist/react/index.d.ts.map +1 -1
  54. package/dist/react/index.js +9 -1
  55. package/dist/react/providers/language-provider.d.ts +18 -0
  56. package/dist/react/providers/language-provider.d.ts.map +1 -0
  57. package/dist/react/providers/language-provider.js +24 -0
  58. package/dist/react/providers/storefront-client-provider.d.ts +7 -2
  59. package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
  60. package/dist/react/providers/storefront-client-provider.js +14 -3
  61. package/dist/react/providers/storefront-provider.d.ts +7 -1
  62. package/dist/react/providers/storefront-provider.d.ts.map +1 -1
  63. package/dist/react/providers/storefront-provider.js +11 -4
  64. package/dist/react/stores/cart.context.d.ts +12 -0
  65. package/dist/react/stores/cart.context.d.ts.map +1 -0
  66. package/dist/react/stores/cart.context.js +3 -0
  67. package/dist/react/stores/cart.store.d.ts +71 -0
  68. package/dist/react/stores/cart.store.d.ts.map +1 -0
  69. package/dist/react/stores/cart.store.js +166 -0
  70. package/dist/react/stores/currency.store.d.ts +6 -9
  71. package/dist/react/stores/currency.store.d.ts.map +1 -1
  72. package/dist/react/stores/currency.store.js +5 -22
  73. package/dist/react/stores/language.store.d.ts +33 -0
  74. package/dist/react/stores/language.store.d.ts.map +1 -0
  75. package/dist/react/stores/language.store.js +67 -0
  76. package/dist/react/stores/store-context.d.ts +5 -0
  77. package/dist/react/stores/store-context.d.ts.map +1 -1
  78. package/dist/react/stores/store-context.js +14 -0
  79. package/dist/react/types/shop-config.d.ts +19 -0
  80. package/dist/react/types/shop-config.d.ts.map +1 -0
  81. package/dist/react/types/shop-config.js +7 -0
  82. package/package.json +1 -1
  83. package/src/__tests__/unit/bot-protection.test.ts +461 -0
  84. package/src/__tests__/unit/cart-store.test.ts +349 -0
  85. package/src/core/bot-protection/abstract-manager.ts +185 -0
  86. package/src/core/bot-protection/create-manager.ts +37 -0
  87. package/src/core/bot-protection/eucaptcha-manager.ts +88 -0
  88. package/src/core/bot-protection/fallback-manager.ts +43 -0
  89. package/src/core/bot-protection/turnstile-manager.ts +92 -0
  90. package/src/core/bot-protection/types/eucaptcha.d.ts +28 -0
  91. package/src/core/bot-protection/types/turnstile.d.ts +33 -0
  92. package/src/core/cart/cookie-config.ts +13 -0
  93. package/src/core/currency/cookie-config.ts +13 -0
  94. package/src/core/image.ts +75 -0
  95. package/src/core/index.ts +30 -0
  96. package/src/core/language/cookie-config.ts +13 -0
  97. package/src/core/middleware/bot-protection.ts +140 -0
  98. package/src/core/middleware/currency.ts +2 -1
  99. package/src/core/middleware/language.ts +30 -0
  100. package/src/react/bot-protection/bot-protection-context.ts +17 -0
  101. package/src/react/bot-protection/bot-protection-widget.tsx +46 -0
  102. package/src/react/cookies.ts +39 -4
  103. package/src/react/hooks/use-bot-protection.ts +31 -0
  104. package/src/react/index.ts +27 -1
  105. package/src/react/providers/language-provider.tsx +34 -0
  106. package/src/react/providers/storefront-client-provider.tsx +20 -3
  107. package/src/react/providers/storefront-provider.tsx +34 -6
  108. package/src/react/stores/cart.context.ts +10 -0
  109. package/src/react/stores/cart.store.ts +254 -0
  110. package/src/react/stores/currency.store.ts +12 -32
  111. package/src/react/stores/language.store.ts +90 -0
  112. package/src/react/stores/store-context.tsx +21 -0
  113. package/src/react/types/shop-config.ts +22 -0
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Unit tests for createCartStore — DI-based cart state management.
3
+ *
4
+ * Tests factory creation, cookie persistence, UI actions, initCart orchestration
5
+ * (including deduplication), mutations, callbacks, and error handling.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import { createCartStore, type CartActions, type CartData } from '../../react/stores/cart.store';
10
+
11
+ // Mock cookies module
12
+ vi.mock('../../react/cookies', () => ({
13
+ getCookie: vi.fn(() => null),
14
+ setCookie: vi.fn(),
15
+ deleteCookie: vi.fn(),
16
+ }));
17
+
18
+ import { getCookie, setCookie, deleteCookie } from '../../react/cookies';
19
+ const mockGetCookie = vi.mocked(getCookie);
20
+ const mockSetCookie = vi.mocked(setCookie);
21
+ const mockDeleteCookie = vi.mocked(deleteCookie);
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function createMockActions(overrides?: Partial<CartActions>): CartActions {
28
+ return {
29
+ fetchCart: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 1 })),
30
+ createCart: vi.fn(async () => 'new-cart-id'),
31
+ addLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 2 })),
32
+ updateLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 3 })),
33
+ removeLines: vi.fn(async () => ({ id: 'cart-1', totalQuantity: 0 })),
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function createTestStore(
39
+ actions?: CartActions,
40
+ options?: {
41
+ onMutationSuccess?: (action: string, cart: CartData) => void;
42
+ onMutationError?: (action: string, error: unknown) => void;
43
+ },
44
+ ) {
45
+ const mockActions = actions ?? createMockActions();
46
+ return {
47
+ store: createCartStore({
48
+ getActions: () => mockActions,
49
+ onMutationSuccess: options?.onMutationSuccess,
50
+ onMutationError: options?.onMutationError,
51
+ }),
52
+ actions: mockActions,
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Task 3.1: Factory, cookie persistence, UI actions
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe('createCartStore — factory & UI actions', () => {
61
+ afterEach(() => {
62
+ vi.clearAllMocks();
63
+ });
64
+
65
+ it('should create a valid store instance with initial state', () => {
66
+ const { store } = createTestStore();
67
+ const state = store.getState();
68
+
69
+ expect(state.cartId).toBeNull();
70
+ expect(state.isOpen).toBe(false);
71
+ expect(state.isLoading).toBe(false);
72
+ expect(state.error).toBeNull();
73
+ });
74
+
75
+ it('should read initial cartId from cookie', () => {
76
+ mockGetCookie.mockReturnValueOnce('existing-cart-from-cookie');
77
+ const actions = createMockActions();
78
+ const store = createCartStore({ getActions: () => actions });
79
+
80
+ expect(store.getState().cartId).toBe('existing-cart-from-cookie');
81
+ expect(mockGetCookie).toHaveBeenCalledWith('cart-id');
82
+ });
83
+
84
+ it('should write cartId to cookie when set', () => {
85
+ const { store } = createTestStore();
86
+
87
+ store.setState({ cartId: 'new-cart' });
88
+
89
+ expect(mockSetCookie).toHaveBeenCalledWith('cart-id', 'new-cart', { maxAge: 30 * 24 * 60 * 60 });
90
+ });
91
+
92
+ it('should delete cookie when cartId cleared', () => {
93
+ const { store } = createTestStore();
94
+ store.setState({ cartId: 'to-clear' });
95
+ vi.clearAllMocks();
96
+
97
+ store.getState().clearCart();
98
+
99
+ expect(mockDeleteCookie).toHaveBeenCalledWith('cart-id');
100
+ expect(store.getState().cartId).toBeNull();
101
+ });
102
+
103
+ it('openCart should set isOpen to true', () => {
104
+ const { store } = createTestStore();
105
+ store.getState().openCart();
106
+ expect(store.getState().isOpen).toBe(true);
107
+ });
108
+
109
+ it('closeCart should set isOpen to false', () => {
110
+ const { store } = createTestStore();
111
+ store.getState().openCart();
112
+ store.getState().closeCart();
113
+ expect(store.getState().isOpen).toBe(false);
114
+ });
115
+
116
+ it('toggleCart should flip isOpen', () => {
117
+ const { store } = createTestStore();
118
+ expect(store.getState().isOpen).toBe(false);
119
+ store.getState().toggleCart();
120
+ expect(store.getState().isOpen).toBe(true);
121
+ store.getState().toggleCart();
122
+ expect(store.getState().isOpen).toBe(false);
123
+ });
124
+
125
+ it('clearCart should reset all state', () => {
126
+ const { store } = createTestStore();
127
+ store.setState({ cartId: 'cart-1', isOpen: true, isLoading: true, error: new Error('err') });
128
+
129
+ store.getState().clearCart();
130
+
131
+ const state = store.getState();
132
+ expect(state.cartId).toBeNull();
133
+ expect(state.isOpen).toBe(false);
134
+ expect(state.isLoading).toBe(false);
135
+ expect(state.error).toBeNull();
136
+ });
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Task 3.2: initCart orchestration
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe('createCartStore — initCart orchestration', () => {
144
+ afterEach(() => { vi.clearAllMocks(); });
145
+
146
+ it('should fetch existing cart when cartId is set', async () => {
147
+ const actions = createMockActions({
148
+ fetchCart: vi.fn(async () => ({ id: 'existing-cart', totalQuantity: 5 })),
149
+ });
150
+ const { store } = createTestStore(actions);
151
+ store.setState({ cartId: 'existing-cart' });
152
+
153
+ await store.getState().initCart();
154
+
155
+ expect(actions.fetchCart).toHaveBeenCalledWith('existing-cart');
156
+ expect(actions.createCart).not.toHaveBeenCalled();
157
+ expect(store.getState().cartId).toBe('existing-cart');
158
+ expect(store.getState().isLoading).toBe(false);
159
+ });
160
+
161
+ it('should create new cart when fetchCart returns null (expired)', async () => {
162
+ const actions = createMockActions({
163
+ fetchCart: vi.fn(async () => null),
164
+ createCart: vi.fn(async () => 'brand-new-cart'),
165
+ });
166
+ const { store } = createTestStore(actions);
167
+ store.setState({ cartId: 'expired-cart' });
168
+
169
+ await store.getState().initCart();
170
+
171
+ expect(actions.fetchCart).toHaveBeenCalledWith('expired-cart');
172
+ expect(actions.createCart).toHaveBeenCalled();
173
+ expect(store.getState().cartId).toBe('brand-new-cart');
174
+ expect(store.getState().isLoading).toBe(false);
175
+ });
176
+
177
+ it('should create new cart when cartId is null', async () => {
178
+ const actions = createMockActions({
179
+ createCart: vi.fn(async () => 'fresh-cart'),
180
+ });
181
+ const { store } = createTestStore(actions);
182
+
183
+ await store.getState().initCart();
184
+
185
+ expect(actions.fetchCart).not.toHaveBeenCalled();
186
+ expect(actions.createCart).toHaveBeenCalled();
187
+ expect(store.getState().cartId).toBe('fresh-cart');
188
+ });
189
+
190
+ it('should deduplicate concurrent initCart calls', async () => {
191
+ let resolveCreate: (value: string) => void;
192
+ const createPromise = new Promise<string>((resolve) => {
193
+ resolveCreate = resolve;
194
+ });
195
+
196
+ const actions = createMockActions({
197
+ createCart: vi.fn(() => createPromise),
198
+ });
199
+ const { store } = createTestStore(actions);
200
+
201
+ // Fire two concurrent initCart calls
202
+ const p1 = store.getState().initCart();
203
+ const p2 = store.getState().initCart();
204
+
205
+ // Resolve
206
+ resolveCreate!('deduped-cart');
207
+ await Promise.all([p1, p2]);
208
+
209
+ // createCart should be called only once
210
+ expect(actions.createCart).toHaveBeenCalledTimes(1);
211
+ expect(store.getState().cartId).toBe('deduped-cart');
212
+ });
213
+
214
+ it('should set error and call onMutationError on initCart failure', async () => {
215
+ const err = new Error('Network error');
216
+ const actions = createMockActions({
217
+ createCart: vi.fn(async () => { throw err; }),
218
+ });
219
+ const onMutationError = vi.fn();
220
+ const { store } = createTestStore(actions, { onMutationError });
221
+
222
+ await store.getState().initCart();
223
+
224
+ expect(store.getState().error).toBe(err);
225
+ expect(store.getState().isLoading).toBe(false);
226
+ expect(onMutationError).toHaveBeenCalledWith('initCart', err);
227
+ });
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Task 3.3: Mutations and callbacks
232
+ // ---------------------------------------------------------------------------
233
+
234
+ describe('createCartStore — mutations', () => {
235
+ afterEach(() => { vi.clearAllMocks(); });
236
+
237
+ it('addToCart with existing cartId should call addLines', async () => {
238
+ const cartData: CartData = { id: 'cart-1', totalQuantity: 2 };
239
+ const actions = createMockActions({
240
+ addLines: vi.fn(async () => cartData),
241
+ });
242
+ const onMutationSuccess = vi.fn();
243
+ const { store } = createTestStore(actions, { onMutationSuccess });
244
+ store.setState({ cartId: 'cart-1' });
245
+
246
+ await store.getState().addToCart([{ merchandiseId: 'var-1', quantity: 1 }]);
247
+
248
+ expect(actions.addLines).toHaveBeenCalledWith('cart-1', [{ merchandiseId: 'var-1', quantity: 1 }]);
249
+ expect(onMutationSuccess).toHaveBeenCalledWith('addToCart', cartData);
250
+ expect(store.getState().isLoading).toBe(false);
251
+ });
252
+
253
+ it('addToCart without cartId should auto-init then addLines', async () => {
254
+ const actions = createMockActions({
255
+ createCart: vi.fn(async () => 'auto-cart'),
256
+ addLines: vi.fn(async () => ({ id: 'auto-cart', totalQuantity: 1 })),
257
+ });
258
+ const { store } = createTestStore(actions);
259
+
260
+ await store.getState().addToCart([{ merchandiseId: 'var-1' }]);
261
+
262
+ expect(actions.createCart).toHaveBeenCalled();
263
+ expect(actions.addLines).toHaveBeenCalledWith('auto-cart', [{ merchandiseId: 'var-1' }]);
264
+ expect(store.getState().cartId).toBe('auto-cart');
265
+ });
266
+
267
+ it('updateQuantity should call updateLines with correct args', async () => {
268
+ const cartData: CartData = { id: 'cart-1', totalQuantity: 3 };
269
+ const actions = createMockActions({
270
+ updateLines: vi.fn(async () => cartData),
271
+ });
272
+ const onMutationSuccess = vi.fn();
273
+ const { store } = createTestStore(actions, { onMutationSuccess });
274
+ store.setState({ cartId: 'cart-1' });
275
+
276
+ await store.getState().updateQuantity([{ id: 'line-1', quantity: 5 }]);
277
+
278
+ expect(actions.updateLines).toHaveBeenCalledWith('cart-1', [{ id: 'line-1', quantity: 5 }]);
279
+ expect(onMutationSuccess).toHaveBeenCalledWith('updateQuantity', cartData);
280
+ });
281
+
282
+ it('updateQuantity without cartId should set error', async () => {
283
+ const onMutationError = vi.fn();
284
+ const { store, actions } = createTestStore(undefined, { onMutationError });
285
+
286
+ await store.getState().updateQuantity([{ id: 'line-1', quantity: 5 }]);
287
+
288
+ expect(actions.updateLines).not.toHaveBeenCalled();
289
+ expect(store.getState().error).toBeInstanceOf(Error);
290
+ expect(onMutationError).toHaveBeenCalledWith('updateQuantity', expect.any(Error));
291
+ });
292
+
293
+ it('removeFromCart should call removeLines with correct args', async () => {
294
+ const cartData: CartData = { id: 'cart-1', totalQuantity: 0 };
295
+ const actions = createMockActions({
296
+ removeLines: vi.fn(async () => cartData),
297
+ });
298
+ const onMutationSuccess = vi.fn();
299
+ const { store } = createTestStore(actions, { onMutationSuccess });
300
+ store.setState({ cartId: 'cart-1' });
301
+
302
+ await store.getState().removeFromCart(['line-1']);
303
+
304
+ expect(actions.removeLines).toHaveBeenCalledWith('cart-1', ['line-1']);
305
+ expect(onMutationSuccess).toHaveBeenCalledWith('removeFromCart', cartData);
306
+ });
307
+
308
+ it('removeFromCart without cartId should silently return', async () => {
309
+ const { store, actions } = createTestStore();
310
+
311
+ await store.getState().removeFromCart(['line-1']);
312
+
313
+ expect(actions.removeLines).not.toHaveBeenCalled();
314
+ expect(store.getState().isLoading).toBe(false);
315
+ });
316
+
317
+ it('should set error state and call onMutationError on DI failure', async () => {
318
+ const err = new Error('GraphQL error');
319
+ const actions = createMockActions({
320
+ addLines: vi.fn(async () => { throw err; }),
321
+ });
322
+ const onMutationError = vi.fn();
323
+ const { store } = createTestStore(actions, { onMutationError });
324
+ store.setState({ cartId: 'cart-1' });
325
+
326
+ await store.getState().addToCart([{ merchandiseId: 'var-1' }]);
327
+
328
+ expect(store.getState().error).toBe(err);
329
+ expect(store.getState().isLoading).toBe(false);
330
+ expect(onMutationError).toHaveBeenCalledWith('addToCart', err);
331
+ });
332
+
333
+ it('should call getActions on every operation (freshness)', async () => {
334
+ const actions = createMockActions();
335
+ const getActions = vi.fn(() => actions);
336
+ const store = createCartStore({
337
+ getActions,
338
+ onMutationSuccess: vi.fn(),
339
+ });
340
+ store.setState({ cartId: 'cart-1' });
341
+
342
+ await store.getState().addToCart([{ merchandiseId: 'v1' }]);
343
+ await store.getState().updateQuantity([{ id: 'l1', quantity: 2 }]);
344
+ await store.getState().removeFromCart(['l1']);
345
+
346
+ // getActions called at least once per operation
347
+ expect(getActions.mock.calls.length).toBeGreaterThanOrEqual(3);
348
+ });
349
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AbstractBotProtectionManager — base class for all bot protection providers.
3
+ *
4
+ * DRY: singleton script loading, lazy mount, in-flight deduplication, timeout.
5
+ * Subclasses override only: _loadScript(), _renderWidget(), _executeChallenge(), _destroyWidget().
6
+ *
7
+ * Framework-agnostic (DOM APIs only) — works in React, Vue, Svelte, vanilla JS.
8
+ */
9
+
10
+ import type { BotProtectionTokenProvider } from '../middleware/bot-protection';
11
+
12
+ export abstract class AbstractBotProtectionManager implements BotProtectionTokenProvider {
13
+ protected readonly siteKey: string;
14
+ protected readonly scriptUrl: string;
15
+
16
+ /** Singleton script loading promise — one load per provider globally */
17
+ private scriptPromise: Promise<void> | null = null;
18
+ /** In-flight deduplication — prevents double-submit */
19
+ private inFlight: Promise<string | null> | null = null;
20
+ /** Whether the widget has been mounted */
21
+ protected widgetId: string | null = null;
22
+ /** Container element for the widget */
23
+ protected container: HTMLElement | null = null;
24
+
25
+ constructor(siteKey: string, scriptUrl: string) {
26
+ this.siteKey = siteKey;
27
+ this.scriptUrl = scriptUrl;
28
+ }
29
+
30
+ /**
31
+ * Load the provider's script tag. Singleton — only loads once.
32
+ * Exposed publicly so React wrapper can trigger preload.
33
+ */
34
+ loadScript(): Promise<void> {
35
+ if (this.scriptPromise) return this.scriptPromise;
36
+
37
+ this.scriptPromise = new Promise<void>((resolve, reject) => {
38
+ // Skip in SSR
39
+ if (typeof document === 'undefined') {
40
+ resolve();
41
+ return;
42
+ }
43
+
44
+ // Check if script already exists
45
+ const existing = document.querySelector(`script[src="${this.scriptUrl}"]`);
46
+ if (existing) {
47
+ // Script tag exists — wait for API to be available
48
+ this._waitForApi().then(resolve).catch(reject);
49
+ return;
50
+ }
51
+
52
+ const script = document.createElement('script');
53
+ script.src = this.scriptUrl;
54
+ script.async = true;
55
+ script.defer = true;
56
+
57
+ // Set up onload callback if provider supports it
58
+ this._setupOnloadCallback(resolve);
59
+
60
+ script.onload = () => {
61
+ this._waitForApi().then(resolve).catch(reject);
62
+ };
63
+
64
+ script.onerror = () => {
65
+ this.scriptPromise = null; // Allow retry
66
+ reject(new Error(`Failed to load bot protection script: ${this.scriptUrl}`));
67
+ };
68
+
69
+ document.head.appendChild(script);
70
+ });
71
+
72
+ return this.scriptPromise;
73
+ }
74
+
75
+ /**
76
+ * Mount the invisible widget into a container element.
77
+ * Lazy — called automatically on first execute() if not mounted.
78
+ */
79
+ mount(container: HTMLElement): void {
80
+ this.container = container;
81
+ }
82
+
83
+ /**
84
+ * Execute challenge and get a fresh token.
85
+ * In-flight dedup + timeout + lazy mount + lazy script load.
86
+ */
87
+ async execute(options?: { action?: string; timeoutMs?: number }): Promise<string | null> {
88
+ // In-flight deduplication — return existing promise if one is running
89
+ if (this.inFlight) return this.inFlight;
90
+
91
+ const timeoutMs = options?.timeoutMs ?? 10000;
92
+
93
+ this.inFlight = this._doExecute(options?.action, timeoutMs).finally(() => {
94
+ this.inFlight = null;
95
+ });
96
+
97
+ return this.inFlight;
98
+ }
99
+
100
+ /**
101
+ * Cleanup widget and resources.
102
+ */
103
+ destroy(): void {
104
+ try {
105
+ if (this.widgetId) {
106
+ this._destroyWidget(this.widgetId);
107
+ }
108
+ } catch {
109
+ // Ignore cleanup errors
110
+ }
111
+ this.widgetId = null;
112
+ this.container = null;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Private execution pipeline
117
+ // ---------------------------------------------------------------------------
118
+
119
+ private async _doExecute(action: string | undefined, timeoutMs: number): Promise<string | null> {
120
+ try {
121
+ // 1. Lazy script load
122
+ await this.loadScript();
123
+
124
+ // 2. Lazy mount — create container if needed
125
+ if (!this.widgetId) {
126
+ await this._ensureMounted(action);
127
+ }
128
+
129
+ if (!this.widgetId) {
130
+ return null; // Mount failed — fail-open
131
+ }
132
+
133
+ // 3. Execute challenge with timeout
134
+ const tokenPromise = this._executeChallenge(this.widgetId, action);
135
+
136
+ const timeoutPromise = new Promise<null>((resolve) => {
137
+ setTimeout(() => resolve(null), timeoutMs);
138
+ });
139
+
140
+ return await Promise.race([tokenPromise, timeoutPromise]);
141
+ } catch {
142
+ // Any error → return null (fail-open at middleware level)
143
+ return null;
144
+ }
145
+ }
146
+
147
+ private async _ensureMounted(action?: string): Promise<void> {
148
+ // Create an invisible container if none provided
149
+ if (!this.container && typeof document !== 'undefined') {
150
+ this.container = document.createElement('div');
151
+ this.container.style.display = 'none';
152
+ this.container.setAttribute('data-bot-protection', 'true');
153
+ document.body.appendChild(this.container);
154
+ }
155
+
156
+ if (!this.container) return;
157
+
158
+ try {
159
+ this.widgetId = this._renderWidget(this.container, action);
160
+ } catch {
161
+ this.widgetId = null;
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Abstract methods — subclass responsibility
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** Wait for the provider API to be available on window */
170
+ protected abstract _waitForApi(): Promise<void>;
171
+
172
+ /** Optional: setup global onload callback for the script */
173
+ protected _setupOnloadCallback(_resolve: () => void): void {
174
+ // Default: no-op. Override if provider uses a callback pattern.
175
+ }
176
+
177
+ /** Render the invisible widget. Return widget ID. */
178
+ protected abstract _renderWidget(container: HTMLElement, action?: string): string;
179
+
180
+ /** Execute the challenge and return a token. Called after mount. */
181
+ protected abstract _executeChallenge(widgetId: string, action?: string): Promise<string | null>;
182
+
183
+ /** Destroy/remove the widget. */
184
+ protected abstract _destroyWidget(widgetId: string): void;
185
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Bot protection manager factory.
3
+ *
4
+ * Creates the appropriate manager based on provider config from shop query.
5
+ * Supports runtime fallback chain via FallbackBotProtectionManager.
6
+ */
7
+
8
+ import type { BotProtectionTokenProvider, BotProtectionConfig, BotProtectionProviderConfig } from '../middleware/bot-protection';
9
+ import { EuCaptchaManager } from './eucaptcha-manager';
10
+ import { TurnstileManager } from './turnstile-manager';
11
+ import { FallbackBotProtectionManager } from './fallback-manager';
12
+
13
+ /**
14
+ * Create a single provider manager instance.
15
+ */
16
+ function createSingleManager(config: BotProtectionProviderConfig): BotProtectionTokenProvider {
17
+ switch (config.provider) {
18
+ case 'eucaptcha':
19
+ return new EuCaptchaManager(config.siteKey, config.scriptUrl);
20
+ case 'turnstile':
21
+ return new TurnstileManager(config.siteKey, config.scriptUrl);
22
+ default:
23
+ throw new Error(`Unknown bot protection provider: ${config.provider}`);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Create a bot protection manager with optional fallback chain.
29
+ *
30
+ * @param config - Bot protection configuration from shop query
31
+ * @returns BotProtectionTokenProvider (FallbackBotProtectionManager if fallback configured)
32
+ */
33
+ export function createBotProtectionManager(config: BotProtectionConfig): BotProtectionTokenProvider {
34
+ const primary = createSingleManager(config.primary);
35
+ const fallback = config.fallback ? createSingleManager(config.fallback) : null;
36
+ return new FallbackBotProtectionManager(primary, fallback);
37
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * EuCaptchaManager — EU CAPTCHA (Myra Security) bot protection provider.
3
+ *
4
+ * Default provider — GDPR by design, EU-hosted (Germany), privacy-first.
5
+ * Implements BotProtectionTokenProvider via AbstractBotProtectionManager.
6
+ * Invisible behavioral analysis, zero user interaction.
7
+ */
8
+
9
+ /// <reference path="./types/eucaptcha.d.ts" />
10
+ import { AbstractBotProtectionManager } from './abstract-manager';
11
+
12
+ export class EuCaptchaManager extends AbstractBotProtectionManager {
13
+ protected _waitForApi(): Promise<void> {
14
+ if (typeof window !== 'undefined' && window.eucaptcha) {
15
+ return Promise.resolve();
16
+ }
17
+
18
+ return new Promise<void>((resolve) => {
19
+ const check = setInterval(() => {
20
+ if (typeof window !== 'undefined' && window.eucaptcha) {
21
+ clearInterval(check);
22
+ resolve();
23
+ }
24
+ }, 50);
25
+
26
+ // Safety timeout
27
+ setTimeout(() => {
28
+ clearInterval(check);
29
+ resolve();
30
+ }, 15000);
31
+ });
32
+ }
33
+
34
+ protected _renderWidget(container: HTMLElement, action?: string): string {
35
+ if (!window.eucaptcha) {
36
+ throw new Error('EU CAPTCHA API not loaded');
37
+ }
38
+
39
+ return window.eucaptcha.render(container, {
40
+ sitekey: this.siteKey,
41
+ size: 'invisible',
42
+ action,
43
+ });
44
+ }
45
+
46
+ protected _executeChallenge(widgetId: string, action?: string): Promise<string | null> {
47
+ return new Promise<string | null>((resolve) => {
48
+ if (!window.eucaptcha) {
49
+ resolve(null);
50
+ return;
51
+ }
52
+
53
+ // Reset for fresh token
54
+ window.eucaptcha.reset(widgetId);
55
+
56
+ // Remove old widget and re-render with callback
57
+ if (!this.container) {
58
+ resolve(null);
59
+ return;
60
+ }
61
+
62
+ // Re-render with callback to capture token
63
+ this.widgetId = window.eucaptcha.render(this.container, {
64
+ sitekey: this.siteKey,
65
+ size: 'invisible',
66
+ action,
67
+ callback: (token: string) => {
68
+ resolve(token);
69
+ },
70
+ 'error-callback': () => {
71
+ resolve(null);
72
+ },
73
+ 'expired-callback': () => {
74
+ resolve(null);
75
+ },
76
+ });
77
+
78
+ // Trigger execution
79
+ window.eucaptcha.execute(this.widgetId);
80
+ });
81
+ }
82
+
83
+ protected _destroyWidget(widgetId: string): void {
84
+ if (window.eucaptcha) {
85
+ window.eucaptcha.reset(widgetId);
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * FallbackBotProtectionManager — runtime fallback chain.
3
+ *
4
+ * Tries primary provider, falls back to secondary, ultimately returns null (fail-open).
5
+ * RULE: NEVER block the customer — fail-open is the ultimate fallback.
6
+ */
7
+
8
+ import type { BotProtectionTokenProvider } from '../middleware/bot-protection';
9
+
10
+ export class FallbackBotProtectionManager implements BotProtectionTokenProvider {
11
+ constructor(
12
+ private readonly primary: BotProtectionTokenProvider,
13
+ private readonly fallback: BotProtectionTokenProvider | null,
14
+ ) {}
15
+
16
+ async execute(options?: { action?: string; timeoutMs?: number }): Promise<string | null> {
17
+ // 1. Try primary
18
+ try {
19
+ const token = await this.primary.execute(options);
20
+ if (token) return token;
21
+ } catch {
22
+ // Primary failed — try fallback
23
+ }
24
+
25
+ // 2. Try fallback
26
+ if (this.fallback) {
27
+ try {
28
+ const token = await this.fallback.execute(options);
29
+ if (token) return token;
30
+ } catch {
31
+ // Fallback also failed
32
+ }
33
+ }
34
+
35
+ // 3. Ultimate fail-open: return null -> middleware decides per-operation strategy
36
+ return null;
37
+ }
38
+
39
+ destroy(): void {
40
+ this.primary.destroy();
41
+ this.fallback?.destroy();
42
+ }
43
+ }