create-brainerce-store 1.17.0 → 1.19.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 (65) hide show
  1. package/dist/index.js +31 -9
  2. package/messages/en.json +366 -359
  3. package/messages/he.json +366 -359
  4. package/package.json +45 -45
  5. package/templates/nextjs/base/next.config.ts +31 -31
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
  7. package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
  8. package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
  9. package/templates/nextjs/base/src/app/account/page.tsx +122 -122
  10. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
  11. package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
  12. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
  14. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
  15. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
  16. package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
  17. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
  18. package/templates/nextjs/base/src/app/cart/page.tsx +204 -199
  19. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
  20. package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
  21. package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
  22. package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
  23. package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
  24. package/templates/nextjs/base/src/app/login/page.tsx +59 -59
  25. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
  26. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +254 -254
  27. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
  28. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
  29. package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
  30. package/templates/nextjs/base/src/app/products/page.tsx +431 -431
  31. package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
  32. package/templates/nextjs/base/src/app/register/page.tsx +65 -65
  33. package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
  34. package/templates/nextjs/base/src/app/robots.ts +14 -14
  35. package/templates/nextjs/base/src/app/sitemap.ts +25 -25
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
  38. package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
  39. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  40. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
  41. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
  42. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  43. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  44. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  45. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
  46. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
  47. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +519 -473
  48. package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
  49. package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
  50. package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
  51. package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
  52. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  53. package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
  54. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
  55. package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
  56. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
  58. package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
  59. package/templates/nextjs/base/src/lib/auth.ts +149 -149
  60. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
  61. package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
  62. package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
  63. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
  64. package/templates/nextjs/base/src/lib/translations.ts +0 -11
  65. package/templates/nextjs/base/src/middleware.ts +0 -25
@@ -1,473 +1,519 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useRef, useCallback } from 'react';
4
- import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
5
- import { getClient } from '@/lib/brainerce';
6
- import { useTranslations } from '@/lib/translations';
7
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
- import { cn } from '@/lib/utils';
9
-
10
- /**
11
- * Backward-compat defaults when backend doesn't return clientSdk.
12
- */
13
- const LEGACY_GROW_SDK: PaymentClientSdk = {
14
- renderType: 'sdk-widget',
15
- scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
16
- globalName: 'growPayment',
17
- initMethod: 'init',
18
- renderMethod: 'renderPaymentOptions',
19
- containerId: 'grow-payment-container',
20
- initConfig: { version: 1, environment: 'DEV' },
21
- additionalScripts: [
22
- { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
23
- ],
24
- bodyStyles:
25
- '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
26
- };
27
-
28
- interface PaymentStepProps {
29
- checkoutId: string;
30
- className?: string;
31
- }
32
-
33
- function resolveClientSdk(
34
- intent: PaymentIntent | null,
35
- preloadedSdk?: PaymentClientSdk | null
36
- ): PaymentClientSdk {
37
- const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
38
- const runtimeSdk = intent?.clientSdk;
39
- if (fullSdk) {
40
- if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
41
- return {
42
- ...fullSdk,
43
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
44
- ...(runtimeSdk.initConfig
45
- ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
46
- : {}),
47
- };
48
- }
49
- const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
50
- if (legacy && runtimeSdk) {
51
- return {
52
- ...legacy,
53
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
54
- ...(runtimeSdk.initConfig
55
- ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
56
- : {}),
57
- };
58
- }
59
- if (legacy) return legacy;
60
- return { renderType: 'redirect' };
61
- }
62
-
63
- function extractMessage(response: unknown): string {
64
- if (typeof response === 'string') return response;
65
- return (response as { message?: string })?.message || '';
66
- }
67
-
68
- export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
69
- const t = useTranslations('checkout');
70
- const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
71
- const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
72
- const [loading, setLoading] = useState(true);
73
- const [error, setError] = useState<string | null>(null);
74
- const [sdkReady, setSdkReady] = useState(false);
75
- const walletOpenRef = useRef(false);
76
- const initialized = useRef(false);
77
-
78
- // Stable refs for SDK event callbacks (avoids stale closures in onload)
79
- const cbRef = useRef({
80
- onSuccess: (_r: unknown) => {},
81
- onFailure: (_r: unknown) => {},
82
- onError: (_r: unknown) => {},
83
- onTimeout: () => {},
84
- onWalletChange: (_s: string) => {},
85
- retryRender: () => {},
86
- });
87
-
88
- const handleSuccess = useCallback(
89
- async (response: unknown) => {
90
- console.info('Payment SDK success:', JSON.stringify(response));
91
- try {
92
- const client = getClient();
93
- const resp = response as Record<string, unknown>;
94
- const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
95
- | Record<string, unknown>
96
- | undefined;
97
- await client.confirmSdkPayment(checkoutId, data || undefined);
98
- } catch (err) {
99
- console.warn('Failed to confirm payment with backend:', err);
100
- }
101
- window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
102
- },
103
- [checkoutId]
104
- );
105
-
106
- cbRef.current = {
107
- onSuccess: handleSuccess,
108
- onFailure: (response: unknown) => {
109
- console.error('Payment SDK failure:', response);
110
- setError(extractMessage(response) || t('paymentError'));
111
- },
112
- onError: (response: unknown) => {
113
- const TRANSIENT = [
114
- 'Wallet not initialized',
115
- "SDK was not loaded as needed and therefore can't run",
116
- ];
117
- const msg = extractMessage(response);
118
- if (TRANSIENT.some((e) => msg.includes(e))) {
119
- console.info('Payment SDK: transient error, retrying render in 1s:', msg);
120
- setTimeout(() => cbRef.current.retryRender(), 1000);
121
- return;
122
- }
123
- console.error('Payment SDK error:', response);
124
- setError(msg || t('paymentError'));
125
- },
126
- onTimeout: () => {
127
- console.warn('Payment SDK: wallet timed out');
128
- setError(t('paymentTimedOut'));
129
- },
130
- onWalletChange: (state: string) => {
131
- console.info('Payment SDK wallet state:', state);
132
- if (state === 'open') {
133
- walletOpenRef.current = true;
134
- setSdkReady(true);
135
- }
136
- if (state === 'close') setSdkReady(false);
137
- },
138
- retryRender: () => {},
139
- };
140
-
141
- // =========================================================================
142
- // MAIN EFFECT — Follows Grow SDK docs exactly:
143
- //
144
- // Step 1: Load gs.min.js (insertBefore, as docs show)
145
- // Step 2: s.onload → growPayment.init({ environment, version, events })
146
- // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
147
- // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
148
- // Step 4: growPayment.renderPaymentOptions(authCode)
149
- //
150
- // "call createPaymentProcess right before you need to render the wallet"
151
- // =========================================================================
152
- useEffect(() => {
153
- if (initialized.current) return;
154
- initialized.current = true;
155
-
156
- const client = getClient();
157
- const successUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
158
- const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
159
-
160
- let sdkInitDone = false;
161
- let currentSdk: PaymentClientSdk | null = null;
162
- const cleanups: (() => void)[] = [];
163
-
164
- // --- Load SDK script exactly as Grow docs show ---
165
- function loadScript(sdk: PaymentClientSdk) {
166
- if (!sdk.scriptUrl || !sdk.globalName) return;
167
-
168
- // Inject bodyStyles
169
- if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
170
- const style = document.createElement('style');
171
- style.setAttribute('data-payment-sdk', 'true');
172
- style.textContent = sdk.bodyStyles;
173
- document.head.appendChild(style);
174
- cleanups.push(() => style.remove());
175
- }
176
-
177
- // Additional scripts (Apple Pay etc.) — fire and forget
178
- if (sdk.additionalScripts) {
179
- for (const extra of sdk.additionalScripts) {
180
- if (document.querySelector(`script[src="${extra.url}"]`)) continue;
181
- const s = document.createElement('script');
182
- s.type = 'text/javascript';
183
- s.async = true;
184
- s.src = extra.url;
185
- const ref = document.getElementsByTagName('script')[0];
186
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
187
- else document.head.appendChild(s);
188
- }
189
- }
190
-
191
- // Already loaded? Init immediately
192
- if ((window as any)[sdk.globalName]) {
193
- initSdk(sdk);
194
- return;
195
- }
196
-
197
- // Already loading (from a previous call)? Wait for it instead of duplicating
198
- if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
199
- const waitId = setInterval(() => {
200
- if ((window as any)[sdk.globalName!]) {
201
- clearInterval(waitId);
202
- initSdk(sdk);
203
- }
204
- }, 100);
205
- cleanups.push(() => clearInterval(waitId));
206
- return;
207
- }
208
-
209
- // Load main SDK — insertBefore first <script> as Grow docs show
210
- const s = document.createElement('script');
211
- s.type = 'text/javascript';
212
- s.async = true;
213
- s.src = sdk.scriptUrl;
214
- s.onload = () => initSdk(sdk); // init DIRECTLY in onload
215
- s.onerror = () => {
216
- console.error('Payment SDK: script load failed');
217
- setError(t('failedToLoadPaymentSdk'));
218
- };
219
- const ref = document.getElementsByTagName('script')[0];
220
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
221
- else document.head.appendChild(s);
222
- }
223
-
224
- // --- Init: called in s.onload (as Grow docs require) ---
225
- function initSdk(sdk: PaymentClientSdk) {
226
- if (sdkInitDone) return; // Guard against double init
227
-
228
- const global = (window as any)[sdk.globalName!];
229
- if (!global) {
230
- setError(t('failedToLoadPaymentSdk'));
231
- return;
232
- }
233
-
234
- const method = sdk.initMethod || 'init';
235
- const config = {
236
- ...(sdk.initConfig || {}),
237
- events: {
238
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
239
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
240
- onError: (r: unknown) => cbRef.current.onError(r),
241
- onTimeout: () => cbRef.current.onTimeout(),
242
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
243
- },
244
- };
245
-
246
- console.info(`Payment SDK: calling ${method}()`);
247
- global[method](config);
248
- sdkInitDone = true;
249
- }
250
-
251
- // --- Render: call once, then safety-net retries if wallet doesn't open ---
252
- // Grow SDK sometimes silently swallows renderPaymentOptions when its
253
- // internal resources (mp.min.js etc.) aren't fully loaded yet.
254
- // Strategy: render once, then retry up to 3 times with increasing delays
255
- // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
256
- let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
257
- let renderAttempts = 0;
258
- const MAX_RENDER_ATTEMPTS = 4;
259
-
260
- function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
261
- const global = (window as any)[sdk.globalName!];
262
- if (!global || walletOpenRef.current) return;
263
-
264
- const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
265
- const renderArg = sdk.renderArg || intent.clientSecret;
266
- renderAttempts++;
267
-
268
- try {
269
- global[renderMethod](renderArg);
270
- console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
271
- } catch (err) {
272
- console.info('Payment SDK: render threw, will retry in 1s');
273
- }
274
-
275
- // Safety net: if wallet doesn't open within a delay, retry
276
- if (renderAttempts < MAX_RENDER_ATTEMPTS) {
277
- const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
278
- const retryId = setTimeout(() => {
279
- if (!walletOpenRef.current) {
280
- console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
281
- renderPayment(sdk, intent);
282
- }
283
- }, delay);
284
- cleanups.push(() => clearTimeout(retryId));
285
- }
286
- }
287
-
288
- function retryRender() {
289
- if (pendingRender && !walletOpenRef.current) {
290
- renderPayment(pendingRender.sdk, pendingRender.intent);
291
- }
292
- }
293
-
294
- // =============================================
295
- // Execution flow
296
- // =============================================
297
-
298
- // A) Get SDK config from providers (fast, no wallet timer)
299
- const providerPromise = client
300
- .getPaymentProviders()
301
- .then((res) => {
302
- const sdk = res.defaultProvider?.clientSdk;
303
- if (sdk) setPreloadedSdk(sdk);
304
- return sdk || null;
305
- })
306
- .catch(() => null);
307
-
308
- // B) Load + init SDK as early as possible
309
- providerPromise.then((providerSdk) => {
310
- if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
311
- currentSdk = providerSdk;
312
- loadScript(providerSdk);
313
- }
314
- });
315
-
316
- // C) Create payment intent (starts wallet timer)
317
- const intentPromise = client
318
- .createPaymentIntent(checkoutId, { successUrl, cancelUrl })
319
- .then((intent) => {
320
- setPaymentIntent(intent);
321
- return intent;
322
- })
323
- .catch((err) => {
324
- setError(err instanceof Error ? err.message : t('paymentError'));
325
- return null;
326
- })
327
- .finally(() => setLoading(false));
328
-
329
- // D) When both ready: resolve final SDK config and render
330
- Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
331
- if (!intent) return;
332
-
333
- const sdk = resolveClientSdk(intent, providerSdk);
334
- currentSdk = sdk;
335
-
336
- if (sdk.renderType === 'redirect') {
337
- window.location.href = intent.clientSecret;
338
- return;
339
- }
340
- if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
341
-
342
- // Store for retryRender from onError callback
343
- pendingRender = { sdk, intent };
344
- cbRef.current.retryRender = retryRender;
345
-
346
- // If SDK wasn't loaded from providers, load + init now
347
- if (!sdkInitDone) {
348
- loadScript(sdk);
349
- // Wait for init to complete, then render once
350
- const id = setInterval(() => {
351
- if (sdkInitDone) {
352
- clearInterval(id);
353
- renderPayment(sdk, intent);
354
- }
355
- }, 100);
356
- cleanups.push(() => clearInterval(id));
357
- return;
358
- }
359
-
360
- // Re-init with final config if environment changed
361
- if (sdk.initConfig?.environment && currentSdk) {
362
- const global = (window as any)[sdk.globalName];
363
- if (global) {
364
- const method = sdk.initMethod || 'init';
365
- global[method]({
366
- ...(sdk.initConfig || {}),
367
- events: {
368
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
369
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
370
- onError: (r: unknown) => cbRef.current.onError(r),
371
- onTimeout: () => cbRef.current.onTimeout(),
372
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
373
- },
374
- });
375
- }
376
- }
377
-
378
- // SDK ready — render once
379
- renderPayment(sdk, intent);
380
- });
381
-
382
- return () => cleanups.forEach((fn) => fn());
383
- }, [checkoutId]);
384
-
385
- // --- UI ---
386
-
387
- if (loading) {
388
- return (
389
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
390
- <LoadingSpinner size="lg" />
391
- <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
392
- </div>
393
- );
394
- }
395
-
396
- if (error) {
397
- const isNotConfigured =
398
- error.toLowerCase().includes('not configured') ||
399
- error.toLowerCase().includes('no payment') ||
400
- error.toLowerCase().includes('provider');
401
- return (
402
- <div className={cn('py-12 text-center', className)}>
403
- <svg
404
- className="text-muted-foreground mx-auto mb-4 h-12 w-12"
405
- fill="none"
406
- viewBox="0 0 24 24"
407
- stroke="currentColor"
408
- >
409
- <path
410
- strokeLinecap="round"
411
- strokeLinejoin="round"
412
- strokeWidth={1.5}
413
- d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
414
- />
415
- </svg>
416
- <h3 className="text-foreground mb-2 text-lg font-semibold">
417
- {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
418
- </h3>
419
- <p className="text-muted-foreground mx-auto max-w-md text-sm">
420
- {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
421
- </p>
422
- </div>
423
- );
424
- }
425
-
426
- if (!paymentIntent) return null;
427
-
428
- const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
429
-
430
- if (sdk.renderType === 'sdk-widget') {
431
- const containerId =
432
- sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
433
- return (
434
- <div className={cn('py-4', className)}>
435
- {!sdkReady && (
436
- <div className="flex flex-col items-center justify-center py-8">
437
- <LoadingSpinner size="lg" />
438
- <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
439
- </div>
440
- )}
441
- <div id={containerId} />
442
- </div>
443
- );
444
- }
445
-
446
- if (sdk.renderType === 'iframe') {
447
- return (
448
- <div className={cn('py-4', className)}>
449
- <iframe
450
- src={paymentIntent.clientSecret}
451
- className="w-full border-0"
452
- style={{ minHeight: '500px' }}
453
- title={t('payment')}
454
- allow="payment"
455
- />
456
- </div>
457
- );
458
- }
459
-
460
- return (
461
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
462
- <LoadingSpinner size="lg" />
463
- <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
464
- <p className="text-muted-foreground mt-2 text-xs">
465
- {t('redirectingHint')}
466
- <a href={paymentIntent.clientSecret} className="text-primary hover:underline">
467
- {t('clickHere')}
468
- </a>
469
- .
470
- </p>
471
- </div>
472
- );
473
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef, useCallback } from 'react';
4
+ import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
5
+ import { getClient } from '@/lib/brainerce';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ /**
11
+ * Backward-compat defaults when backend doesn't return clientSdk.
12
+ */
13
+ const LEGACY_GROW_SDK: PaymentClientSdk = {
14
+ renderType: 'sdk-widget',
15
+ scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
16
+ globalName: 'growPayment',
17
+ initMethod: 'init',
18
+ renderMethod: 'renderPaymentOptions',
19
+ containerId: 'grow-payment-container',
20
+ initConfig: { version: 1, environment: 'DEV' },
21
+ additionalScripts: [
22
+ { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
23
+ ],
24
+ bodyStyles:
25
+ '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
26
+ };
27
+
28
+ interface PaymentStepProps {
29
+ checkoutId: string;
30
+ className?: string;
31
+ }
32
+
33
+ function resolveClientSdk(
34
+ intent: PaymentIntent | null,
35
+ preloadedSdk?: PaymentClientSdk | null
36
+ ): PaymentClientSdk {
37
+ const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
38
+ const runtimeSdk = intent?.clientSdk;
39
+ if (fullSdk) {
40
+ if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
41
+ return {
42
+ ...fullSdk,
43
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
44
+ ...(runtimeSdk.initConfig
45
+ ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
46
+ : {}),
47
+ };
48
+ }
49
+ const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
50
+ if (legacy && runtimeSdk) {
51
+ return {
52
+ ...legacy,
53
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
54
+ ...(runtimeSdk.initConfig
55
+ ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
56
+ : {}),
57
+ };
58
+ }
59
+ if (legacy) return legacy;
60
+ return { renderType: 'redirect' };
61
+ }
62
+
63
+ function extractMessage(response: unknown): string {
64
+ if (typeof response === 'string') return response;
65
+ return (response as { message?: string })?.message || '';
66
+ }
67
+
68
+ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
69
+ const t = useTranslations('checkout');
70
+ const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
71
+ const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
72
+ const [loading, setLoading] = useState(true);
73
+ const [error, setError] = useState<string | null>(null);
74
+ const [sdkReady, setSdkReady] = useState(false);
75
+ const walletOpenRef = useRef(false);
76
+ const initialized = useRef(false);
77
+
78
+ // Stable refs for SDK event callbacks (avoids stale closures in onload)
79
+ const cbRef = useRef({
80
+ onSuccess: (_r: unknown) => {},
81
+ onFailure: (_r: unknown) => {},
82
+ onError: (_r: unknown) => {},
83
+ onTimeout: () => {},
84
+ onWalletChange: (_s: string) => {},
85
+ retryRender: () => {},
86
+ });
87
+
88
+ const handleSuccess = useCallback(
89
+ async (response: unknown) => {
90
+ console.info('Payment SDK success:', JSON.stringify(response));
91
+ try {
92
+ const client = getClient();
93
+ const resp = response as Record<string, unknown>;
94
+ const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
95
+ | Record<string, unknown>
96
+ | undefined;
97
+ await client.confirmSdkPayment(checkoutId, data || undefined);
98
+ } catch (err) {
99
+ console.warn('Failed to confirm payment with backend:', err);
100
+ }
101
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
102
+ },
103
+ [checkoutId]
104
+ );
105
+
106
+ cbRef.current = {
107
+ onSuccess: handleSuccess,
108
+ onFailure: (response: unknown) => {
109
+ console.error('Payment SDK failure:', response);
110
+ setError(extractMessage(response) || t('paymentError'));
111
+ },
112
+ onError: (response: unknown) => {
113
+ const TRANSIENT = [
114
+ 'Wallet not initialized',
115
+ "SDK was not loaded as needed and therefore can't run",
116
+ ];
117
+ const msg = extractMessage(response);
118
+ if (TRANSIENT.some((e) => msg.includes(e))) {
119
+ console.info('Payment SDK: transient error, retrying render in 1s:', msg);
120
+ setTimeout(() => cbRef.current.retryRender(), 1000);
121
+ return;
122
+ }
123
+ console.error('Payment SDK error:', response);
124
+ setError(msg || t('paymentError'));
125
+ },
126
+ onTimeout: () => {
127
+ console.warn('Payment SDK: wallet timed out');
128
+ setError(t('paymentTimedOut'));
129
+ },
130
+ onWalletChange: (state: string) => {
131
+ console.info('Payment SDK wallet state:', state);
132
+ if (state === 'open') {
133
+ walletOpenRef.current = true;
134
+ setSdkReady(true);
135
+ }
136
+ if (state === 'close') setSdkReady(false);
137
+ },
138
+ retryRender: () => {},
139
+ };
140
+
141
+ // =========================================================================
142
+ // MAIN EFFECT — Follows Grow SDK docs exactly:
143
+ //
144
+ // Step 1: Load gs.min.js (insertBefore, as docs show)
145
+ // Step 2: s.onload → growPayment.init({ environment, version, events })
146
+ // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
147
+ // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
148
+ // Step 4: growPayment.renderPaymentOptions(authCode)
149
+ //
150
+ // "call createPaymentProcess right before you need to render the wallet"
151
+ // =========================================================================
152
+ useEffect(() => {
153
+ if (initialized.current) return;
154
+ initialized.current = true;
155
+
156
+ const client = getClient();
157
+ const successUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
158
+ const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
159
+
160
+ let sdkInitDone = false;
161
+ let currentSdk: PaymentClientSdk | null = null;
162
+ const cleanups: (() => void)[] = [];
163
+
164
+ // --- Load SDK script exactly as Grow docs show ---
165
+ function loadScript(sdk: PaymentClientSdk) {
166
+ if (!sdk.scriptUrl || !sdk.globalName) return;
167
+
168
+ // Inject bodyStyles
169
+ if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
170
+ const style = document.createElement('style');
171
+ style.setAttribute('data-payment-sdk', 'true');
172
+ style.textContent = sdk.bodyStyles;
173
+ document.head.appendChild(style);
174
+ cleanups.push(() => style.remove());
175
+ }
176
+
177
+ // Additional scripts (Apple Pay etc.) — fire and forget
178
+ if (sdk.additionalScripts) {
179
+ for (const extra of sdk.additionalScripts) {
180
+ if (document.querySelector(`script[src="${extra.url}"]`)) continue;
181
+ const s = document.createElement('script');
182
+ s.type = 'text/javascript';
183
+ s.async = true;
184
+ s.src = extra.url;
185
+ const ref = document.getElementsByTagName('script')[0];
186
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
187
+ else document.head.appendChild(s);
188
+ }
189
+ }
190
+
191
+ // Already loaded? Init immediately
192
+ if ((window as any)[sdk.globalName]) {
193
+ initSdk(sdk);
194
+ return;
195
+ }
196
+
197
+ // Already loading (from a previous call)? Wait for it instead of duplicating
198
+ if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
199
+ const waitId = setInterval(() => {
200
+ if ((window as any)[sdk.globalName!]) {
201
+ clearInterval(waitId);
202
+ initSdk(sdk);
203
+ }
204
+ }, 100);
205
+ cleanups.push(() => clearInterval(waitId));
206
+ return;
207
+ }
208
+
209
+ // Load main SDK — insertBefore first <script> as Grow docs show
210
+ const s = document.createElement('script');
211
+ s.type = 'text/javascript';
212
+ s.async = true;
213
+ s.src = sdk.scriptUrl;
214
+ s.onload = () => initSdk(sdk); // init DIRECTLY in onload
215
+ s.onerror = () => {
216
+ console.error('Payment SDK: script load failed');
217
+ setError(t('failedToLoadPaymentSdk'));
218
+ };
219
+ const ref = document.getElementsByTagName('script')[0];
220
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
221
+ else document.head.appendChild(s);
222
+ }
223
+
224
+ // --- Init: called in s.onload (as Grow docs require) ---
225
+ function initSdk(sdk: PaymentClientSdk) {
226
+ if (sdkInitDone) return; // Guard against double init
227
+
228
+ const global = (window as any)[sdk.globalName!];
229
+ if (!global) {
230
+ setError(t('failedToLoadPaymentSdk'));
231
+ return;
232
+ }
233
+
234
+ const method = sdk.initMethod || 'init';
235
+ const config = {
236
+ ...(sdk.initConfig || {}),
237
+ events: {
238
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
239
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
240
+ onError: (r: unknown) => cbRef.current.onError(r),
241
+ onTimeout: () => cbRef.current.onTimeout(),
242
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
243
+ },
244
+ };
245
+
246
+ console.info(`Payment SDK: calling ${method}()`);
247
+ global[method](config);
248
+ sdkInitDone = true;
249
+ }
250
+
251
+ // --- Render: call once, then safety-net retries if wallet doesn't open ---
252
+ // Grow SDK sometimes silently swallows renderPaymentOptions when its
253
+ // internal resources (mp.min.js etc.) aren't fully loaded yet.
254
+ // Strategy: render once, then retry up to 3 times with increasing delays
255
+ // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
256
+ let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
257
+ let renderAttempts = 0;
258
+ const MAX_RENDER_ATTEMPTS = 4;
259
+
260
+ function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
261
+ const global = (window as any)[sdk.globalName!];
262
+ if (!global || walletOpenRef.current) return;
263
+
264
+ const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
265
+ const renderArg = sdk.renderArg || intent.clientSecret;
266
+ renderAttempts++;
267
+
268
+ try {
269
+ global[renderMethod](renderArg);
270
+ console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
271
+ } catch (err) {
272
+ console.info('Payment SDK: render threw, will retry in 1s');
273
+ }
274
+
275
+ // Safety net: if wallet doesn't open within a delay, retry
276
+ if (renderAttempts < MAX_RENDER_ATTEMPTS) {
277
+ const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
278
+ const retryId = setTimeout(() => {
279
+ if (!walletOpenRef.current) {
280
+ console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
281
+ renderPayment(sdk, intent);
282
+ }
283
+ }, delay);
284
+ cleanups.push(() => clearTimeout(retryId));
285
+ }
286
+ }
287
+
288
+ function retryRender() {
289
+ if (pendingRender && !walletOpenRef.current) {
290
+ renderPayment(pendingRender.sdk, pendingRender.intent);
291
+ }
292
+ }
293
+
294
+ // =============================================
295
+ // Execution flow
296
+ // =============================================
297
+
298
+ // A) Get SDK config from providers (fast, no wallet timer)
299
+ const providerPromise = client
300
+ .getPaymentProviders()
301
+ .then((res) => {
302
+ const sdk = res.defaultProvider?.clientSdk;
303
+ if (sdk) setPreloadedSdk(sdk);
304
+ return sdk || null;
305
+ })
306
+ .catch(() => null);
307
+
308
+ // B) Load + init SDK as early as possible (skip for sandbox)
309
+ providerPromise.then((providerSdk) => {
310
+ if (providerSdk?.renderType === 'sandbox') return;
311
+ if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
312
+ currentSdk = providerSdk;
313
+ loadScript(providerSdk);
314
+ }
315
+ });
316
+
317
+ // C) Create payment intent (starts wallet timer)
318
+ const intentPromise = client
319
+ .createPaymentIntent(checkoutId, { successUrl, cancelUrl })
320
+ .then((intent) => {
321
+ setPaymentIntent(intent);
322
+ return intent;
323
+ })
324
+ .catch((err) => {
325
+ setError(err instanceof Error ? err.message : t('paymentError'));
326
+ return null;
327
+ })
328
+ .finally(() => setLoading(false));
329
+
330
+ // D) When both ready: resolve final SDK config and render
331
+ Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
332
+ if (!intent) return;
333
+
334
+ const sdk = resolveClientSdk(intent, providerSdk);
335
+ currentSdk = sdk;
336
+
337
+ // Sandbox mode — no SDK to load, UI handles it
338
+ if (sdk.renderType === 'sandbox') return;
339
+
340
+ if (sdk.renderType === 'redirect') {
341
+ window.location.href = intent.clientSecret;
342
+ return;
343
+ }
344
+ if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
345
+
346
+ // Store for retryRender from onError callback
347
+ pendingRender = { sdk, intent };
348
+ cbRef.current.retryRender = retryRender;
349
+
350
+ // If SDK wasn't loaded from providers, load + init now
351
+ if (!sdkInitDone) {
352
+ loadScript(sdk);
353
+ // Wait for init to complete, then render once
354
+ const id = setInterval(() => {
355
+ if (sdkInitDone) {
356
+ clearInterval(id);
357
+ renderPayment(sdk, intent);
358
+ }
359
+ }, 100);
360
+ cleanups.push(() => clearInterval(id));
361
+ return;
362
+ }
363
+
364
+ // Re-init with final config if environment changed
365
+ if (sdk.initConfig?.environment && currentSdk) {
366
+ const global = (window as any)[sdk.globalName];
367
+ if (global) {
368
+ const method = sdk.initMethod || 'init';
369
+ global[method]({
370
+ ...(sdk.initConfig || {}),
371
+ events: {
372
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
373
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
374
+ onError: (r: unknown) => cbRef.current.onError(r),
375
+ onTimeout: () => cbRef.current.onTimeout(),
376
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
377
+ },
378
+ });
379
+ }
380
+ }
381
+
382
+ // SDK ready render once
383
+ renderPayment(sdk, intent);
384
+ });
385
+
386
+ return () => cleanups.forEach((fn) => fn());
387
+ }, [checkoutId]);
388
+
389
+ // --- UI ---
390
+
391
+ if (loading) {
392
+ return (
393
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
394
+ <LoadingSpinner size="lg" />
395
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
396
+ </div>
397
+ );
398
+ }
399
+
400
+ if (error) {
401
+ const isNotConfigured =
402
+ error.toLowerCase().includes('not configured') ||
403
+ error.toLowerCase().includes('no payment') ||
404
+ error.toLowerCase().includes('provider');
405
+ return (
406
+ <div className={cn('py-12 text-center', className)}>
407
+ <svg
408
+ className="text-muted-foreground mx-auto mb-4 h-12 w-12"
409
+ fill="none"
410
+ viewBox="0 0 24 24"
411
+ stroke="currentColor"
412
+ >
413
+ <path
414
+ strokeLinecap="round"
415
+ strokeLinejoin="round"
416
+ strokeWidth={1.5}
417
+ d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
418
+ />
419
+ </svg>
420
+ <h3 className="text-foreground mb-2 text-lg font-semibold">
421
+ {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
422
+ </h3>
423
+ <p className="text-muted-foreground mx-auto max-w-md text-sm">
424
+ {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
425
+ </p>
426
+ </div>
427
+ );
428
+ }
429
+
430
+ if (!paymentIntent) return null;
431
+
432
+ const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
433
+
434
+ if (sdk.renderType === 'sandbox') {
435
+ const handleCompleteSandbox = async () => {
436
+ setLoading(true);
437
+ try {
438
+ const client = getClient();
439
+ await client.completeGuestCheckout(checkoutId);
440
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
441
+ } catch (err) {
442
+ setError(err instanceof Error ? err.message : t('paymentError'));
443
+ setLoading(false);
444
+ }
445
+ };
446
+
447
+ return (
448
+ <div className={cn('py-8 text-center', className)}>
449
+ <div className="mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-6">
450
+ <svg
451
+ className="mx-auto mb-3 h-10 w-10 text-amber-500"
452
+ fill="none"
453
+ viewBox="0 0 24 24"
454
+ stroke="currentColor"
455
+ >
456
+ <path
457
+ strokeLinecap="round"
458
+ strokeLinejoin="round"
459
+ strokeWidth={1.5}
460
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
461
+ />
462
+ </svg>
463
+ <h3 className="text-foreground mb-1 text-lg font-semibold">{t('sandboxTitle')}</h3>
464
+ <p className="text-muted-foreground mb-4 text-sm">{t('sandboxDescription')}</p>
465
+ <button
466
+ onClick={handleCompleteSandbox}
467
+ className="inline-flex items-center rounded-md bg-amber-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-amber-600"
468
+ >
469
+ {t('completeTestOrder')}
470
+ </button>
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ if (sdk.renderType === 'sdk-widget') {
477
+ const containerId =
478
+ sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
479
+ return (
480
+ <div className={cn('py-4', className)}>
481
+ {!sdkReady && (
482
+ <div className="flex flex-col items-center justify-center py-8">
483
+ <LoadingSpinner size="lg" />
484
+ <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
485
+ </div>
486
+ )}
487
+ <div id={containerId} />
488
+ </div>
489
+ );
490
+ }
491
+
492
+ if (sdk.renderType === 'iframe') {
493
+ return (
494
+ <div className={cn('py-4', className)}>
495
+ <iframe
496
+ src={paymentIntent.clientSecret}
497
+ className="w-full border-0"
498
+ style={{ minHeight: '500px' }}
499
+ title={t('payment')}
500
+ allow="payment"
501
+ />
502
+ </div>
503
+ );
504
+ }
505
+
506
+ return (
507
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
508
+ <LoadingSpinner size="lg" />
509
+ <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
510
+ <p className="text-muted-foreground mt-2 text-xs">
511
+ {t('redirectingHint')}
512
+ <a href={paymentIntent.clientSecret} className="text-primary hover:underline">
513
+ {t('clickHere')}
514
+ </a>
515
+ .
516
+ </p>
517
+ </div>
518
+ );
519
+ }