create-brainerce-store 1.83.0 → 1.85.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 (43) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +847 -761
  21. package/templates/nextjs/base/src/core/lib/render-mode.ts +49 -0
  22. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  23. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  24. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  25. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  26. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  27. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  28. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  29. package/templates/nextjs/designs/atelier/globals.css +408 -402
  30. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  31. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  35. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  36. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  37. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  39. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  41. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  42. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  43. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
@@ -1,761 +1,847 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useRef, useCallback, type CSSProperties } from 'react';
4
- import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
5
- import { formatPrice } from 'brainerce';
6
- import { BrainerceError } from 'brainerce';
7
- import { getClient } from '@/core/lib/brainerce';
8
- import { useTranslations } from '@/core/lib/translations';
9
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
10
- import { useStoreInfo } from '@/core/providers/store-provider';
11
- import { cn } from '@/core/lib/utils';
12
- import { isAllowedPaymentUrl, isValidCheckoutId, safePaymentRedirect } from '@/core/lib/safe-redirect';
13
-
14
- /**
15
- * Backward-compat defaults when backend doesn't return clientSdk.
16
- */
17
- const LEGACY_GROW_SDK: PaymentClientSdk = {
18
- renderType: 'sdk-widget',
19
- scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
20
- globalName: 'growPayment',
21
- initMethod: 'init',
22
- renderMethod: 'renderPaymentOptions',
23
- containerId: 'grow-payment-container',
24
- initConfig: { version: 1, environment: 'DEV' },
25
- additionalScripts: [
26
- { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
27
- ],
28
- bodyStyles:
29
- '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
30
- };
31
-
32
- interface PaymentStepProps {
33
- checkoutId: string;
34
- className?: string;
35
- }
36
-
37
- function resolveClientSdk(
38
- intent: PaymentIntent | null,
39
- preloadedSdk?: PaymentClientSdk | null
40
- ): PaymentClientSdk {
41
- // Runtime SDK (returned by the payment app in the intent) wins over the
42
- // preloaded manifest SDK. This lets a provider return a different renderType
43
- // per-installation (e.g. Cardcom returning 'embedded-fields' when the
44
- // merchant opts in, while the manifest default stays 'iframe').
45
- const fullSdk = [intent?.clientSdk, preloadedSdk].find((s) => s?.renderType);
46
- const runtimeSdk = intent?.clientSdk;
47
- if (fullSdk) {
48
- if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
49
- return {
50
- ...fullSdk,
51
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
52
- ...(runtimeSdk.initConfig
53
- ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
54
- : {}),
55
- };
56
- }
57
- const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
58
- if (legacy && runtimeSdk) {
59
- return {
60
- ...legacy,
61
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
62
- ...(runtimeSdk.initConfig
63
- ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
64
- : {}),
65
- };
66
- }
67
- if (legacy) return legacy;
68
- return { renderType: 'redirect' };
69
- }
70
-
71
- function extractMessage(response: unknown): string {
72
- if (typeof response === 'string') return response;
73
- return (response as { message?: string })?.message || '';
74
- }
75
-
76
- export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
77
- const t = useTranslations('checkout');
78
- const { storeInfo } = useStoreInfo();
79
-
80
- const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
81
-
82
- // The provider's payment URL. `clientSdk.renderArg` is the URL; `clientSecret`
83
- // is only a fallback for providers that duplicate it there. Reading
84
- // clientSecret alone breaks providers that return a real identifier (MAX,
85
- // Takbull) — the iframe/link silently points at an id instead of a page.
86
- const paymentIntentUrl =
87
- paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
88
- const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
89
- const [loading, setLoading] = useState(true);
90
- const [error, setError] = useState<string | null>(null);
91
- const [sdkReady, setSdkReady] = useState(false);
92
- // Set by the Cardcom OpenFields embed page via `brainerce:resize` postMessage.
93
- // Presence of this value is how we distinguish our own compact embed page
94
- // from a provider's hosted page — used to narrow the modal + auto-size the
95
- // iframe instead of reserving the tall LowProfile footprint.
96
- const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
97
- const walletOpenRef = useRef(false);
98
- const initialized = useRef(false);
99
-
100
- // Stable refs for SDK event callbacks (avoids stale closures in onload)
101
- const cbRef = useRef({
102
- onSuccess: (_r: unknown) => {},
103
- onFailure: (_r: unknown) => {},
104
- onError: (_r: unknown) => {},
105
- onTimeout: () => {},
106
- onWalletChange: (_s: string) => {},
107
- retryRender: () => {},
108
- });
109
-
110
- const handleSuccess = useCallback(
111
- async (response: unknown) => {
112
- console.info('Payment SDK success:', JSON.stringify(response));
113
- try {
114
- const client = getClient();
115
- const resp = response as Record<string, unknown>;
116
- const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
117
- | Record<string, unknown>
118
- | undefined;
119
- await client.confirmSdkPayment(checkoutId, data || undefined);
120
- } catch (err) {
121
- console.warn('Failed to confirm payment with backend:', err);
122
- }
123
- window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
124
- },
125
- [checkoutId]
126
- );
127
-
128
- cbRef.current = {
129
- onSuccess: handleSuccess,
130
- onFailure: (response: unknown) => {
131
- console.error('Payment SDK failure:', response);
132
- setError(extractMessage(response) || t('paymentError'));
133
- },
134
- onError: (response: unknown) => {
135
- const TRANSIENT = [
136
- 'Wallet not initialized',
137
- "SDK was not loaded as needed and therefore can't run",
138
- ];
139
- const msg = extractMessage(response);
140
- if (TRANSIENT.some((e) => msg.includes(e))) {
141
- console.info('Payment SDK: transient error, retrying render in 1s:', msg);
142
- setTimeout(() => cbRef.current.retryRender(), 1000);
143
- return;
144
- }
145
- console.error('Payment SDK error:', response);
146
- setError(msg || t('paymentError'));
147
- },
148
- onTimeout: () => {
149
- console.warn('Payment SDK: wallet timed out');
150
- setError(t('paymentTimedOut'));
151
- },
152
- onWalletChange: (state: string) => {
153
- console.info('Payment SDK wallet state:', state);
154
- if (state === 'open') {
155
- walletOpenRef.current = true;
156
- setSdkReady(true);
157
- }
158
- if (state === 'close') setSdkReady(false);
159
- },
160
- retryRender: () => {},
161
- };
162
-
163
- // =========================================================================
164
- // MAIN EFFECT — Follows Grow SDK docs exactly:
165
- //
166
- // Step 1: Load gs.min.js (insertBefore, as docs show)
167
- // Step 2: s.onload → growPayment.init({ environment, version, events })
168
- // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
169
- // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
170
- // Step 4: growPayment.renderPaymentOptions(authCode)
171
- //
172
- // "call createPaymentProcess right before you need to render the wallet"
173
- // =========================================================================
174
- useEffect(() => {
175
- // Defense in depth: the parent already validates checkoutId from URL
176
- // params, but we re-check here so the component is safe to render in any
177
- // context. Invalid id → no SDK loading, no API calls (error UI below).
178
- if (!isValidCheckoutId(checkoutId)) return;
179
- if (initialized.current) return;
180
- initialized.current = true;
181
-
182
- const client = getClient();
183
- const iframeSuccessUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}`;
184
- const iframeFailedUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}&failed=true`;
185
- const redirectSuccessUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
186
- const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
187
-
188
- let sdkInitDone = false;
189
- let currentSdk: PaymentClientSdk | null = null;
190
- const cleanups: (() => void)[] = [];
191
-
192
- // --- Load SDK script exactly as Grow docs show ---
193
- function loadScript(sdk: PaymentClientSdk) {
194
- if (!sdk.scriptUrl || !sdk.globalName) return;
195
-
196
- // Inject bodyStyles
197
- if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
198
- const style = document.createElement('style');
199
- style.setAttribute('data-payment-sdk', 'true');
200
- style.textContent = sdk.bodyStyles;
201
- document.head.appendChild(style);
202
- cleanups.push(() => style.remove());
203
- }
204
-
205
- // Additional scripts (Apple Pay etc.) — fire and forget
206
- if (sdk.additionalScripts) {
207
- for (const extra of sdk.additionalScripts) {
208
- if (document.querySelector(`script[src="${extra.url}"]`)) continue;
209
- const s = document.createElement('script');
210
- s.type = 'text/javascript';
211
- s.async = true;
212
- s.src = extra.url;
213
- const ref = document.getElementsByTagName('script')[0];
214
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
215
- else document.head.appendChild(s);
216
- }
217
- }
218
-
219
- // Already loaded? Init immediately
220
- if ((window as any)[sdk.globalName]) {
221
- initSdk(sdk);
222
- return;
223
- }
224
-
225
- // Already loading (from a previous call)? Wait for it instead of duplicating
226
- if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
227
- const waitId = setInterval(() => {
228
- if ((window as any)[sdk.globalName!]) {
229
- clearInterval(waitId);
230
- initSdk(sdk);
231
- }
232
- }, 100);
233
- cleanups.push(() => clearInterval(waitId));
234
- return;
235
- }
236
-
237
- // Load main SDK — insertBefore first <script> as Grow docs show
238
- const s = document.createElement('script');
239
- s.type = 'text/javascript';
240
- s.async = true;
241
- s.src = sdk.scriptUrl;
242
- s.onload = () => initSdk(sdk); // init DIRECTLY in onload
243
- s.onerror = () => {
244
- console.error('Payment SDK: script load failed');
245
- setError(t('failedToLoadPaymentSdk'));
246
- };
247
- const ref = document.getElementsByTagName('script')[0];
248
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
249
- else document.head.appendChild(s);
250
- }
251
-
252
- // --- Init: called in s.onload (as Grow docs require) ---
253
- function initSdk(sdk: PaymentClientSdk) {
254
- if (sdkInitDone) return; // Guard against double init
255
-
256
- const global = (window as any)[sdk.globalName!];
257
- if (!global) {
258
- setError(t('failedToLoadPaymentSdk'));
259
- return;
260
- }
261
-
262
- const method = sdk.initMethod || 'init';
263
- const config = {
264
- ...(sdk.initConfig || {}),
265
- events: {
266
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
267
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
268
- onError: (r: unknown) => cbRef.current.onError(r),
269
- onTimeout: () => cbRef.current.onTimeout(),
270
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
271
- },
272
- };
273
-
274
- console.info(`Payment SDK: calling ${method}()`);
275
- global[method](config);
276
- sdkInitDone = true;
277
- }
278
-
279
- // --- Render: call once, then safety-net retries if wallet doesn't open ---
280
- // Grow SDK sometimes silently swallows renderPaymentOptions when its
281
- // internal resources (mp.min.js etc.) aren't fully loaded yet.
282
- // Strategy: render once, then retry up to 3 times with increasing delays
283
- // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
284
- let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
285
- let renderAttempts = 0;
286
- const MAX_RENDER_ATTEMPTS = 4;
287
-
288
- function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
289
- const global = (window as any)[sdk.globalName!];
290
- if (!global || walletOpenRef.current) return;
291
-
292
- const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
293
- const renderArg = sdk.renderArg || intent.clientSecret;
294
- renderAttempts++;
295
-
296
- try {
297
- global[renderMethod](renderArg);
298
- console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
299
- } catch (err) {
300
- console.info('Payment SDK: render threw, will retry in 1s');
301
- }
302
-
303
- // Safety net: if wallet doesn't open within a delay, retry
304
- if (renderAttempts < MAX_RENDER_ATTEMPTS) {
305
- const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
306
- const retryId = setTimeout(() => {
307
- if (!walletOpenRef.current) {
308
- console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
309
- renderPayment(sdk, intent);
310
- }
311
- }, delay);
312
- cleanups.push(() => clearTimeout(retryId));
313
- }
314
- }
315
-
316
- function retryRender() {
317
- if (pendingRender && !walletOpenRef.current) {
318
- renderPayment(pendingRender.sdk, pendingRender.intent);
319
- }
320
- }
321
-
322
- // =============================================
323
- // Execution flow
324
- // =============================================
325
-
326
- // A) Get SDK config from providers (fast, no wallet timer)
327
- const providerPromise = client
328
- .getPaymentProviders()
329
- .then((res) => {
330
- const sdk = res.defaultProvider?.clientSdk;
331
- if (sdk) setPreloadedSdk(sdk);
332
- return sdk || null;
333
- })
334
- .catch(() => null);
335
-
336
- // B) Load + init SDK as early as possible (skip for sandbox)
337
- providerPromise.then((providerSdk) => {
338
- if (providerSdk?.renderType === 'sandbox') return;
339
- if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
340
- currentSdk = providerSdk;
341
- loadScript(providerSdk);
342
- }
343
- });
344
-
345
- // C) Create payment intent (starts wallet timer)
346
- // Wait for provider info so we can choose the right success URL:
347
- // iframe providers redirect inside the iframe to /payment-complete (postMessage),
348
- // redirect providers go straight to /order-confirmation.
349
- const intentPromise = providerPromise
350
- .then((providerSdk) => {
351
- const isIframe = providerSdk?.renderType === 'iframe';
352
- const successUrl = isIframe ? iframeSuccessUrl : redirectSuccessUrl;
353
- const failedUrl = isIframe ? iframeFailedUrl : cancelUrl;
354
- return client.createPaymentIntent(checkoutId, {
355
- successUrl,
356
- cancelUrl: failedUrl,
357
- });
358
- })
359
- .then((intent) => {
360
- setPaymentIntent(intent);
361
- return intent;
362
- })
363
- .catch((err) => {
364
- // This is intent CREATION, before any card is entered, so a failure
365
- // here is the merchant's configuration and never the shopper's card.
366
- // The server said "Stripe account is not connected" and the storefront
367
- // printed it verbatim: raw English on a Hebrew store, naming a provider
368
- // to someone who could not install one if they wanted to. Declines come
369
- // from the provider SDK on a different path and keep their own wording,
370
- // which is the part a shopper can actually act on.
371
- console.error('[checkout] could not start payment', err);
372
- setError(t('paymentUnavailable'));
373
- return null;
374
- })
375
- .finally(() => setLoading(false));
376
-
377
- // D) When both ready: resolve final SDK config and render
378
- Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
379
- if (!intent) return;
380
-
381
- const sdk = resolveClientSdk(intent, providerSdk);
382
- currentSdk = sdk;
383
-
384
- // Sandbox mode — no SDK to load, UI handles it
385
- if (sdk.renderType === 'sandbox') return;
386
-
387
- // The URL to send the customer to is `renderArg`; `clientSecret` is only
388
- // a fallback for providers that duplicate the URL into it. Reading
389
- // clientSecret first silently breaks any provider that puts a real
390
- // identifier there (MAX, Takbull) — the host check rejects the id and the
391
- // customer never reaches the payment page. Mirrors the sdk-widget branch
392
- // above, which already prefers renderArg.
393
- const paymentUrl = sdk.renderArg || intent.clientSecret;
394
-
395
- if (sdk.renderType === 'redirect') {
396
- if (!isAllowedPaymentUrl(paymentUrl)) {
397
- setError(t('paymentRedirectBlocked'));
398
- return;
399
- }
400
- safePaymentRedirect(paymentUrl);
401
- return;
402
- }
403
-
404
- // Iframe mode: listen for postMessage from either:
405
- // (1) the same-origin /payment-complete callback page after a provider
406
- // redirect (legacy hosted-page flow), OR
407
- // (2) a Brainerce-hosted embed page on an allowlisted payment host
408
- // that wraps provider-specific logic (e.g. Cardcom OpenFields).
409
- if (sdk.renderType === 'iframe') {
410
- if (!isAllowedPaymentUrl(paymentUrl)) {
411
- setError(t('paymentRedirectBlocked'));
412
- return;
413
- }
414
- const iframeOrigin = (() => {
415
- try {
416
- return new URL(paymentUrl).origin;
417
- } catch {
418
- return '';
419
- }
420
- })();
421
- const handleMessage = (event: MessageEvent) => {
422
- const isSameOrigin = event.origin === window.location.origin;
423
- const isTrustedIframe = iframeOrigin && event.origin === iframeOrigin;
424
- if (!isSameOrigin && !isTrustedIframe) return;
425
- if (event.data?.type === 'brainerce:resize') {
426
- const h = Number((event.data as { height?: unknown }).height);
427
- if (Number.isFinite(h) && h > 0 && h < 4000) setEmbeddedIframeHeight(h);
428
- return;
429
- }
430
- // Embed page asking for a top-level redirect (e.g. Bit express-pay).
431
- // We re-validate against the allowlist even though the URL comes
432
- // from an already-trusted iframe — defense in depth.
433
- if (event.data?.type === 'brainerce:redirect') {
434
- const url = String((event.data as { url?: unknown }).url || '');
435
- if (url) safePaymentRedirect(url);
436
- return;
437
- }
438
- if (event.data?.type !== 'brainerce:payment-complete') return;
439
-
440
- const params = event.data.data as Record<string, string> | undefined;
441
- if (params?.failed === 'true') {
442
- setError(t('paymentError'));
443
- return;
444
- }
445
-
446
- // Map provider-specific params to normalized format for
447
- // server-side verification (e.g. CardCom lowprofilecode → paymentIntentId)
448
- const lowProfileCode = params?.lowprofilecode || params?.LowProfileCode;
449
- const normalized: Record<string, unknown> = { ...params };
450
- if (lowProfileCode) {
451
- normalized.paymentIntentId = lowProfileCode;
452
- }
453
-
454
- // Trigger server-side verification + order creation
455
- handleSuccess(normalized);
456
- };
457
- window.addEventListener('message', handleMessage);
458
- cleanups.push(() => window.removeEventListener('message', handleMessage));
459
- return;
460
- }
461
-
462
- if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
463
-
464
- // Store for retryRender from onError callback
465
- pendingRender = { sdk, intent };
466
- cbRef.current.retryRender = retryRender;
467
-
468
- // If SDK wasn't loaded from providers, load + init now
469
- if (!sdkInitDone) {
470
- loadScript(sdk);
471
- // Wait for init to complete, then render once
472
- const id = setInterval(() => {
473
- if (sdkInitDone) {
474
- clearInterval(id);
475
- renderPayment(sdk, intent);
476
- }
477
- }, 100);
478
- cleanups.push(() => clearInterval(id));
479
- return;
480
- }
481
-
482
- // Re-init with final config if environment changed
483
- if (sdk.initConfig?.environment && currentSdk) {
484
- const global = (window as any)[sdk.globalName];
485
- if (global) {
486
- const method = sdk.initMethod || 'init';
487
- global[method]({
488
- ...(sdk.initConfig || {}),
489
- events: {
490
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
491
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
492
- onError: (r: unknown) => cbRef.current.onError(r),
493
- onTimeout: () => cbRef.current.onTimeout(),
494
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
495
- },
496
- });
497
- }
498
- }
499
-
500
- // SDK ready — render once
501
- renderPayment(sdk, intent);
502
- });
503
-
504
- return () => cleanups.forEach((fn) => fn());
505
- }, [checkoutId]);
506
-
507
- // --- UI ---
508
-
509
- // Invalid checkout id — render the error box instead of ever spinning.
510
- // (Checked after the hooks above so hook order is stable across renders.)
511
- if (!isValidCheckoutId(checkoutId)) {
512
- return (
513
- <div className={cn('border-destructive/50 rounded-md border p-4', className)}>
514
- <p className="text-destructive text-sm">{t('paymentError')}</p>
515
- </div>
516
- );
517
- }
518
-
519
- if (loading) {
520
- return (
521
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
522
- <LoadingSpinner size="lg" />
523
- <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
524
- </div>
525
- );
526
- }
527
-
528
- if (error) {
529
- const isNotConfigured =
530
- error.toLowerCase().includes('not configured') ||
531
- error.toLowerCase().includes('no payment') ||
532
- error.toLowerCase().includes('provider');
533
- return (
534
- <div className={cn('py-12 text-center', className)}>
535
- <svg
536
- className="text-muted-foreground mx-auto mb-4 h-12 w-12"
537
- fill="none"
538
- viewBox="0 0 24 24"
539
- stroke="currentColor"
540
- >
541
- <path
542
- strokeLinecap="round"
543
- strokeLinejoin="round"
544
- strokeWidth={1.5}
545
- 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"
546
- />
547
- </svg>
548
- <h3 className="text-foreground mb-2 text-lg font-semibold">
549
- {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
550
- </h3>
551
- <p className="text-muted-foreground mx-auto max-w-md text-sm">
552
- {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
553
- </p>
554
- </div>
555
- );
556
- }
557
-
558
- if (!paymentIntent) return null;
559
-
560
- const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
561
-
562
- if (sdk.renderType === 'sandbox') {
563
- const handleCompleteSandbox = async () => {
564
- setLoading(true);
565
- try {
566
- const client = getClient();
567
- await client.completeGuestCheckout(checkoutId);
568
- window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
569
- } catch (err) {
570
- // Sandbox never declines, so there is nothing here a shopper can act on
571
- // either.
572
- console.error('[checkout] sandbox completion failed', err);
573
- setError(t('paymentError'));
574
- setLoading(false);
575
- }
576
- };
577
-
578
- return (
579
- <div className={cn('py-8 text-center', className)}>
580
- <div className="mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-6">
581
- <svg
582
- className="mx-auto mb-3 h-10 w-10 text-amber-500"
583
- fill="none"
584
- viewBox="0 0 24 24"
585
- stroke="currentColor"
586
- >
587
- <path
588
- strokeLinecap="round"
589
- strokeLinejoin="round"
590
- strokeWidth={1.5}
591
- 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"
592
- />
593
- </svg>
594
- <h3 className="text-foreground mb-1 text-lg font-semibold">{t('sandboxTitle')}</h3>
595
- <p className="text-muted-foreground mb-4 text-sm">{t('sandboxDescription')}</p>
596
- <button
597
- onClick={handleCompleteSandbox}
598
- 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"
599
- >
600
- {t('completeTestOrder')}
601
- </button>
602
- </div>
603
- </div>
604
- );
605
- }
606
-
607
- if (sdk.renderType === 'sdk-widget') {
608
- const containerId =
609
- sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
610
- return (
611
- <div className={cn('py-4', className)}>
612
- {!sdkReady && (
613
- <div className="flex flex-col items-center justify-center py-8">
614
- <LoadingSpinner size="lg" />
615
- <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
616
- </div>
617
- )}
618
- <div id={containerId} />
619
- </div>
620
- );
621
- }
622
-
623
- if (sdk.renderType === 'iframe') {
624
- if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
625
-
626
- // Detect Brainerce-hosted embed (path-based — works across localhost/
627
- // staging/prod without a domain list) vs. a provider-hosted page. The
628
- // embed page is already brand-styled and compact → render inline in the
629
- // checkout flow. Provider-hosted pages carry their own branding/chrome →
630
- // keep the modal overlay so they don't fight the checkout layout.
631
- const iframeUrlObj = (() => {
632
- try {
633
- return new URL(paymentIntent.clientSecret);
634
- } catch {
635
- return null;
636
- }
637
- })();
638
- const isBrainerceEmbed = iframeUrlObj?.pathname.includes('/embed/') ?? false;
639
-
640
- if (isBrainerceEmbed) {
641
- // Inline: default to a reasonable height until the embed posts its real
642
- // height via `brainerce:resize`. Transition smooths the resize into the
643
- // final measurement.
644
- const hasMeasured = embeddedIframeHeight !== null;
645
- const iframeStyle: CSSProperties = {
646
- height: hasMeasured ? (embeddedIframeHeight as number) : 540,
647
- transition: hasMeasured ? 'height 0.2s ease-out' : undefined,
648
- };
649
- return (
650
- <div className={cn('w-full', className)}>
651
- <iframe
652
- src={paymentIntentUrl}
653
- className="block w-full border-0"
654
- style={iframeStyle}
655
- title={t('payment')}
656
- allow="payment"
657
- />
658
- </div>
659
- );
660
- }
661
-
662
- // Provider-hosted page (e.g. Cardcom LowProfile with full merchant
663
- // branding) — modal overlay keeps it visually contained.
664
- const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
665
- currency: paymentIntent.currency,
666
- }) as string;
667
- const iframeStyle: CSSProperties = { height: '90vh', minHeight: 700 };
668
- return (
669
- <>
670
- {/* Modal overlay */}
671
- <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
672
- <div className="bg-background relative mx-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl shadow-2xl">
673
- {/* Header */}
674
- <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-4">
675
- <div className="flex min-w-0 flex-col">
676
- <span className="text-foreground truncate text-sm font-semibold">
677
- {storeInfo?.name}
678
- </span>
679
- <span className="text-muted-foreground text-xs">{t('payment')}</span>
680
- </div>
681
- <div className="flex items-baseline gap-1.5">
682
- <span className="text-foreground text-lg font-bold tabular-nums">
683
- {formattedAmount}
684
- </span>
685
- <span className="text-muted-foreground text-xs uppercase">
686
- {paymentIntent.currency}
687
- </span>
688
- </div>
689
- <button
690
- onClick={() => {
691
- window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
692
- }}
693
- className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
694
- aria-label="Close"
695
- >
696
- <svg
697
- width="14"
698
- height="14"
699
- viewBox="0 0 14 14"
700
- fill="none"
701
- stroke="currentColor"
702
- strokeWidth="2"
703
- strokeLinecap="round"
704
- >
705
- <path d="M1 1l12 12M13 1L1 13" />
706
- </svg>
707
- </button>
708
- </div>
709
- {/* Iframe body */}
710
- <iframe
711
- src={paymentIntentUrl}
712
- className="w-full border-0"
713
- style={iframeStyle}
714
- title={t('payment')}
715
- allow="payment"
716
- />
717
- {/* Footer */}
718
- <div className="border-border bg-secondary/30 text-muted-foreground flex items-center justify-center gap-2 border-t px-5 py-3 text-xs">
719
- <svg
720
- width="14"
721
- height="14"
722
- viewBox="0 0 24 24"
723
- fill="none"
724
- stroke="currentColor"
725
- strokeWidth="2"
726
- strokeLinecap="round"
727
- strokeLinejoin="round"
728
- aria-hidden="true"
729
- >
730
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
731
- <path d="m9 12 2 2 4-4" />
732
- </svg>
733
- <span>
734
- {t('securePayment')} · <span className="font-medium">Brainerce</span>
735
- </span>
736
- </div>
737
- </div>
738
- </div>
739
- {/* Placeholder so the checkout layout doesn't collapse */}
740
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
741
- <LoadingSpinner size="lg" />
742
- <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
743
- </div>
744
- </>
745
- );
746
- }
747
-
748
- return (
749
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
750
- <LoadingSpinner size="lg" />
751
- <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
752
- <p className="text-muted-foreground mt-2 text-xs">
753
- {t('redirectingHint')}
754
- <a href={paymentIntentUrl} className="text-primary hover:underline">
755
- {t('clickHere')}
756
- </a>
757
- .
758
- </p>
759
- </div>
760
- );
761
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef, useCallback, type CSSProperties } from 'react';
4
+ import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
5
+ import { formatPrice } from 'brainerce';
6
+ import { BrainerceError } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
10
+ import { useStoreInfo } from '@/core/providers/store-provider';
11
+ import { cn } from '@/core/lib/utils';
12
+ import {
13
+ isAllowedPaymentUrl,
14
+ isValidCheckoutId,
15
+ safePaymentRedirect,
16
+ } from '@/core/lib/safe-redirect';
17
+ import { PREFERRED_RENDER_MODE, resolveRenderType } from '@/core/lib/render-mode';
18
+
19
+ /**
20
+ * Backward-compat defaults when backend doesn't return clientSdk.
21
+ */
22
+ const LEGACY_GROW_SDK: PaymentClientSdk = {
23
+ renderType: 'sdk-widget',
24
+ scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
25
+ globalName: 'growPayment',
26
+ initMethod: 'init',
27
+ renderMethod: 'renderPaymentOptions',
28
+ containerId: 'grow-payment-container',
29
+ initConfig: { version: 1, environment: 'DEV' },
30
+ additionalScripts: [
31
+ { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
32
+ ],
33
+ bodyStyles:
34
+ '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
35
+ };
36
+
37
+ interface PaymentStepProps {
38
+ checkoutId: string;
39
+ className?: string;
40
+ }
41
+
42
+ function resolveClientSdk(
43
+ intent: PaymentIntent | null,
44
+ preloadedSdk?: PaymentClientSdk | null
45
+ ): PaymentClientSdk {
46
+ // Runtime SDK (returned by the payment app in the intent) wins over the
47
+ // preloaded manifest SDK. This lets a provider return a different renderType
48
+ // per-installation (e.g. Cardcom returning 'embedded-fields' when the
49
+ // merchant opts in, while the manifest default stays 'iframe').
50
+ const fullSdk = [intent?.clientSdk, preloadedSdk].find((s) => s?.renderType);
51
+ const runtimeSdk = intent?.clientSdk;
52
+ if (fullSdk) {
53
+ if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
54
+ return {
55
+ ...fullSdk,
56
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
57
+ ...(runtimeSdk.initConfig
58
+ ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
59
+ : {}),
60
+ };
61
+ }
62
+ const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
63
+ if (legacy && runtimeSdk) {
64
+ return {
65
+ ...legacy,
66
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
67
+ ...(runtimeSdk.initConfig
68
+ ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
69
+ : {}),
70
+ };
71
+ }
72
+ if (legacy) return legacy;
73
+ return { renderType: 'redirect' };
74
+ }
75
+
76
+ /**
77
+ * Brainerce-hosted embed (Cardcom OpenFields) vs a provider-hosted page.
78
+ * Path-based so it holds across localhost/staging/prod without a domain list.
79
+ */
80
+ function isBrainerceEmbedUrl(url: string): boolean {
81
+ try {
82
+ return new URL(url).pathname.includes('/embed/');
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function extractMessage(response: unknown): string {
89
+ if (typeof response === 'string') return response;
90
+ return (response as { message?: string })?.message || '';
91
+ }
92
+
93
+ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
94
+ const t = useTranslations('checkout');
95
+ const { storeInfo } = useStoreInfo();
96
+
97
+ const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
98
+
99
+ // The provider's payment URL. `clientSdk.renderArg` is the URL; `clientSecret`
100
+ // is only a fallback for providers that duplicate it there. Reading
101
+ // clientSecret alone breaks providers that return a real identifier (MAX,
102
+ // Takbull) — the iframe/link silently points at an id instead of a page.
103
+ const paymentIntentUrl = paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
104
+ const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
105
+ const [loading, setLoading] = useState(true);
106
+ const [error, setError] = useState<string | null>(null);
107
+ const [sdkReady, setSdkReady] = useState(false);
108
+ // Set by the Cardcom OpenFields embed page via `brainerce:resize` postMessage.
109
+ // Presence of this value is how we distinguish our own compact embed page
110
+ // from a provider's hosted page — used to narrow the modal + auto-size the
111
+ // iframe instead of reserving the tall LowProfile footprint.
112
+ const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
113
+ // Provider-hosted iframe modal, closed by the shopper. Without this the
114
+ // modal had no closed state at all: the X did a full navigation to
115
+ // `?canceled=true`, the page resumed on the payment step, a fresh intent
116
+ // re-opened the modal over the "payment canceled" banner, and browser Back
117
+ // did the same — the shopper could never get back to the checkout. Starts
118
+ // closed when the URL already says the previous attempt was canceled.
119
+ const [modalDismissed, setModalDismissed] = useState<boolean>(
120
+ () =>
121
+ typeof window !== 'undefined' &&
122
+ new URLSearchParams(window.location.search).get('canceled') === 'true'
123
+ );
124
+ const walletOpenRef = useRef(false);
125
+ const initialized = useRef(false);
126
+
127
+ // Whether the provider-hosted modal is on screen right now. Derived, not
128
+ // stored, so every branch below agrees on it.
129
+ const providerModalOpen =
130
+ !modalDismissed &&
131
+ !!paymentIntent &&
132
+ resolveClientSdk(paymentIntent, preloadedSdk).renderType === 'iframe' &&
133
+ !isBrainerceEmbedUrl(paymentIntent.clientSecret);
134
+
135
+ // Back closes the modal instead of leaving the shopper inside it. Opening
136
+ // pushes one history entry tagged as ours; popping it (Back, or the X below
137
+ // calling history.back()) dismisses. A provider page that navigates inside
138
+ // the iframe adds entries of its own on top, so Back may need a press per
139
+ // in-frame navigation first — that is the browser's rule, not ours, and it
140
+ // still ends on the checkout rather than looping through the payment page.
141
+ useEffect(() => {
142
+ if (!providerModalOpen) return;
143
+ const marker = { brainercePaymentModal: true };
144
+ window.history.pushState(marker, '');
145
+ const onPop = () => setModalDismissed(true);
146
+ window.addEventListener('popstate', onPop);
147
+ return () => window.removeEventListener('popstate', onPop);
148
+ }, [providerModalOpen]);
149
+
150
+ const closeProviderModal = useCallback(() => {
151
+ if (
152
+ (window.history.state as { brainercePaymentModal?: boolean } | null)?.brainercePaymentModal
153
+ ) {
154
+ // Our entry is on top: popping it fires popstate, which dismisses and
155
+ // leaves the history as it was before the modal opened.
156
+ window.history.back();
157
+ } else {
158
+ setModalDismissed(true);
159
+ }
160
+ }, []);
161
+
162
+ // Stable refs for SDK event callbacks (avoids stale closures in onload)
163
+ const cbRef = useRef({
164
+ onSuccess: (_r: unknown) => {},
165
+ onFailure: (_r: unknown) => {},
166
+ onError: (_r: unknown) => {},
167
+ onTimeout: () => {},
168
+ onWalletChange: (_s: string) => {},
169
+ retryRender: () => {},
170
+ });
171
+
172
+ const handleSuccess = useCallback(
173
+ async (response: unknown) => {
174
+ console.info('Payment SDK success:', JSON.stringify(response));
175
+ try {
176
+ const client = getClient();
177
+ const resp = response as Record<string, unknown>;
178
+ const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
179
+ | Record<string, unknown>
180
+ | undefined;
181
+ await client.confirmSdkPayment(checkoutId, data || undefined);
182
+ } catch (err) {
183
+ console.warn('Failed to confirm payment with backend:', err);
184
+ }
185
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
186
+ },
187
+ [checkoutId]
188
+ );
189
+
190
+ cbRef.current = {
191
+ onSuccess: handleSuccess,
192
+ onFailure: (response: unknown) => {
193
+ console.error('Payment SDK failure:', response);
194
+ setError(extractMessage(response) || t('paymentError'));
195
+ },
196
+ onError: (response: unknown) => {
197
+ const TRANSIENT = [
198
+ 'Wallet not initialized',
199
+ "SDK was not loaded as needed and therefore can't run",
200
+ ];
201
+ const msg = extractMessage(response);
202
+ if (TRANSIENT.some((e) => msg.includes(e))) {
203
+ console.info('Payment SDK: transient error, retrying render in 1s:', msg);
204
+ setTimeout(() => cbRef.current.retryRender(), 1000);
205
+ return;
206
+ }
207
+ console.error('Payment SDK error:', response);
208
+ setError(msg || t('paymentError'));
209
+ },
210
+ onTimeout: () => {
211
+ console.warn('Payment SDK: wallet timed out');
212
+ setError(t('paymentTimedOut'));
213
+ },
214
+ onWalletChange: (state: string) => {
215
+ console.info('Payment SDK wallet state:', state);
216
+ if (state === 'open') {
217
+ walletOpenRef.current = true;
218
+ setSdkReady(true);
219
+ }
220
+ if (state === 'close') setSdkReady(false);
221
+ },
222
+ retryRender: () => {},
223
+ };
224
+
225
+ // =========================================================================
226
+ // MAIN EFFECT — Follows Grow SDK docs exactly:
227
+ //
228
+ // Step 1: Load gs.min.js (insertBefore, as docs show)
229
+ // Step 2: s.onload → growPayment.init({ environment, version, events })
230
+ // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
231
+ // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
232
+ // Step 4: growPayment.renderPaymentOptions(authCode)
233
+ //
234
+ // "call createPaymentProcess right before you need to render the wallet"
235
+ // =========================================================================
236
+ useEffect(() => {
237
+ // Defense in depth: the parent already validates checkoutId from URL
238
+ // params, but we re-check here so the component is safe to render in any
239
+ // context. Invalid id → no SDK loading, no API calls (error UI below).
240
+ if (!isValidCheckoutId(checkoutId)) return;
241
+ if (initialized.current) return;
242
+ initialized.current = true;
243
+
244
+ const client = getClient();
245
+ const iframeSuccessUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}`;
246
+ const iframeFailedUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}&failed=true`;
247
+ const redirectSuccessUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
248
+ const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
249
+
250
+ let sdkInitDone = false;
251
+ let currentSdk: PaymentClientSdk | null = null;
252
+ const cleanups: (() => void)[] = [];
253
+
254
+ // --- Load SDK script exactly as Grow docs show ---
255
+ function loadScript(sdk: PaymentClientSdk) {
256
+ if (!sdk.scriptUrl || !sdk.globalName) return;
257
+
258
+ // Inject bodyStyles
259
+ if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
260
+ const style = document.createElement('style');
261
+ style.setAttribute('data-payment-sdk', 'true');
262
+ style.textContent = sdk.bodyStyles;
263
+ document.head.appendChild(style);
264
+ cleanups.push(() => style.remove());
265
+ }
266
+
267
+ // Additional scripts (Apple Pay etc.) — fire and forget
268
+ if (sdk.additionalScripts) {
269
+ for (const extra of sdk.additionalScripts) {
270
+ if (document.querySelector(`script[src="${extra.url}"]`)) continue;
271
+ const s = document.createElement('script');
272
+ s.type = 'text/javascript';
273
+ s.async = true;
274
+ s.src = extra.url;
275
+ const ref = document.getElementsByTagName('script')[0];
276
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
277
+ else document.head.appendChild(s);
278
+ }
279
+ }
280
+
281
+ // Already loaded? Init immediately
282
+ if ((window as any)[sdk.globalName]) {
283
+ initSdk(sdk);
284
+ return;
285
+ }
286
+
287
+ // Already loading (from a previous call)? Wait for it instead of duplicating
288
+ if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
289
+ const waitId = setInterval(() => {
290
+ if ((window as any)[sdk.globalName!]) {
291
+ clearInterval(waitId);
292
+ initSdk(sdk);
293
+ }
294
+ }, 100);
295
+ cleanups.push(() => clearInterval(waitId));
296
+ return;
297
+ }
298
+
299
+ // Load main SDK — insertBefore first <script> as Grow docs show
300
+ const s = document.createElement('script');
301
+ s.type = 'text/javascript';
302
+ s.async = true;
303
+ s.src = sdk.scriptUrl;
304
+ s.onload = () => initSdk(sdk); // init DIRECTLY in onload
305
+ s.onerror = () => {
306
+ console.error('Payment SDK: script load failed');
307
+ setError(t('failedToLoadPaymentSdk'));
308
+ };
309
+ const ref = document.getElementsByTagName('script')[0];
310
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
311
+ else document.head.appendChild(s);
312
+ }
313
+
314
+ // --- Init: called in s.onload (as Grow docs require) ---
315
+ function initSdk(sdk: PaymentClientSdk) {
316
+ if (sdkInitDone) return; // Guard against double init
317
+
318
+ const global = (window as any)[sdk.globalName!];
319
+ if (!global) {
320
+ setError(t('failedToLoadPaymentSdk'));
321
+ return;
322
+ }
323
+
324
+ const method = sdk.initMethod || 'init';
325
+ const config = {
326
+ ...(sdk.initConfig || {}),
327
+ events: {
328
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
329
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
330
+ onError: (r: unknown) => cbRef.current.onError(r),
331
+ onTimeout: () => cbRef.current.onTimeout(),
332
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
333
+ },
334
+ };
335
+
336
+ console.info(`Payment SDK: calling ${method}()`);
337
+ global[method](config);
338
+ sdkInitDone = true;
339
+ }
340
+
341
+ // --- Render: call once, then safety-net retries if wallet doesn't open ---
342
+ // Grow SDK sometimes silently swallows renderPaymentOptions when its
343
+ // internal resources (mp.min.js etc.) aren't fully loaded yet.
344
+ // Strategy: render once, then retry up to 3 times with increasing delays
345
+ // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
346
+ let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
347
+ let renderAttempts = 0;
348
+ const MAX_RENDER_ATTEMPTS = 4;
349
+
350
+ function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
351
+ const global = (window as any)[sdk.globalName!];
352
+ if (!global || walletOpenRef.current) return;
353
+
354
+ const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
355
+ const renderArg = sdk.renderArg || intent.clientSecret;
356
+ renderAttempts++;
357
+
358
+ try {
359
+ global[renderMethod](renderArg);
360
+ console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
361
+ } catch (err) {
362
+ console.info('Payment SDK: render threw, will retry in 1s');
363
+ }
364
+
365
+ // Safety net: if wallet doesn't open within a delay, retry
366
+ if (renderAttempts < MAX_RENDER_ATTEMPTS) {
367
+ const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
368
+ const retryId = setTimeout(() => {
369
+ if (!walletOpenRef.current) {
370
+ console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
371
+ renderPayment(sdk, intent);
372
+ }
373
+ }, delay);
374
+ cleanups.push(() => clearTimeout(retryId));
375
+ }
376
+ }
377
+
378
+ function retryRender() {
379
+ if (pendingRender && !walletOpenRef.current) {
380
+ renderPayment(pendingRender.sdk, pendingRender.intent);
381
+ }
382
+ }
383
+
384
+ // =============================================
385
+ // Execution flow
386
+ // =============================================
387
+
388
+ // A) Get SDK config from providers (fast, no wallet timer)
389
+ const providerPromise = client
390
+ .getPaymentProviders()
391
+ .then((res) => {
392
+ const sdk = res.defaultProvider?.clientSdk;
393
+ if (sdk) setPreloadedSdk(sdk);
394
+ return sdk || null;
395
+ })
396
+ .catch(() => null);
397
+
398
+ // B) Load + init SDK as early as possible (skip for sandbox)
399
+ providerPromise.then((providerSdk) => {
400
+ if (providerSdk?.renderType === 'sandbox') return;
401
+ if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
402
+ currentSdk = providerSdk;
403
+ loadScript(providerSdk);
404
+ }
405
+ });
406
+
407
+ // C) Create payment intent (starts wallet timer)
408
+ // Wait for provider info so we can choose the right success URL:
409
+ // iframe providers redirect inside the iframe to /payment-complete (postMessage),
410
+ // redirect providers go straight to /order-confirmation.
411
+ //
412
+ // The mode is PREDICTED with the platform's own resolution rule
413
+ // (resolveRenderType): the provider's default, unless this storefront asks
414
+ // for a mode the provider declares in `displayModes`. The prediction only
415
+ // picks the return URL; the branch below still reads the `renderType` that
416
+ // comes BACK on the intent, never the prediction.
417
+ const intentPromise = providerPromise
418
+ .then((providerSdk) => {
419
+ const expectedRenderType = resolveRenderType(providerSdk, PREFERRED_RENDER_MODE);
420
+ const isIframe = expectedRenderType === 'iframe';
421
+ const successUrl = isIframe ? iframeSuccessUrl : redirectSuccessUrl;
422
+ const failedUrl = isIframe ? iframeFailedUrl : cancelUrl;
423
+ // `preferredRenderType` is only sent when a preference is set, so a
424
+ // storefront on the provider default sends the same body it always
425
+ // has (and an SDK whose option type predates it still compiles).
426
+ return client.createPaymentIntent(checkoutId, {
427
+ successUrl,
428
+ cancelUrl: failedUrl,
429
+ ...(PREFERRED_RENDER_MODE ? { preferredRenderType: PREFERRED_RENDER_MODE } : {}),
430
+ });
431
+ })
432
+ .then((intent) => {
433
+ setPaymentIntent(intent);
434
+ return intent;
435
+ })
436
+ .catch((err) => {
437
+ // This is intent CREATION, before any card is entered, so a failure
438
+ // here is the merchant's configuration and never the shopper's card.
439
+ // The server said "Stripe account is not connected" and the storefront
440
+ // printed it verbatim: raw English on a Hebrew store, naming a provider
441
+ // to someone who could not install one if they wanted to. Declines come
442
+ // from the provider SDK on a different path and keep their own wording,
443
+ // which is the part a shopper can actually act on.
444
+ console.error('[checkout] could not start payment', err);
445
+ setError(t('paymentUnavailable'));
446
+ return null;
447
+ })
448
+ .finally(() => setLoading(false));
449
+
450
+ // D) When both ready: resolve final SDK config and render
451
+ Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
452
+ if (!intent) return;
453
+
454
+ const sdk = resolveClientSdk(intent, providerSdk);
455
+ currentSdk = sdk;
456
+
457
+ // Sandbox mode — no SDK to load, UI handles it
458
+ if (sdk.renderType === 'sandbox') return;
459
+
460
+ // The URL to send the customer to is `renderArg`; `clientSecret` is only
461
+ // a fallback for providers that duplicate the URL into it. Reading
462
+ // clientSecret first silently breaks any provider that puts a real
463
+ // identifier there (MAX, Takbull) — the host check rejects the id and the
464
+ // customer never reaches the payment page. Mirrors the sdk-widget branch
465
+ // above, which already prefers renderArg.
466
+ const paymentUrl = sdk.renderArg || intent.clientSecret;
467
+
468
+ if (sdk.renderType === 'redirect') {
469
+ if (!isAllowedPaymentUrl(paymentUrl)) {
470
+ setError(t('paymentRedirectBlocked'));
471
+ return;
472
+ }
473
+ safePaymentRedirect(paymentUrl);
474
+ return;
475
+ }
476
+
477
+ // Iframe mode: listen for postMessage from either:
478
+ // (1) the same-origin /payment-complete callback page after a provider
479
+ // redirect (legacy hosted-page flow), OR
480
+ // (2) a Brainerce-hosted embed page on an allowlisted payment host
481
+ // that wraps provider-specific logic (e.g. Cardcom OpenFields).
482
+ if (sdk.renderType === 'iframe') {
483
+ if (!isAllowedPaymentUrl(paymentUrl)) {
484
+ setError(t('paymentRedirectBlocked'));
485
+ return;
486
+ }
487
+ const iframeOrigin = (() => {
488
+ try {
489
+ return new URL(paymentUrl).origin;
490
+ } catch {
491
+ return '';
492
+ }
493
+ })();
494
+ const handleMessage = (event: MessageEvent) => {
495
+ const isSameOrigin = event.origin === window.location.origin;
496
+ const isTrustedIframe = iframeOrigin && event.origin === iframeOrigin;
497
+ if (!isSameOrigin && !isTrustedIframe) return;
498
+ if (event.data?.type === 'brainerce:resize') {
499
+ const h = Number((event.data as { height?: unknown }).height);
500
+ if (Number.isFinite(h) && h > 0 && h < 4000) setEmbeddedIframeHeight(h);
501
+ return;
502
+ }
503
+ // Embed page asking for a top-level redirect (e.g. Bit express-pay).
504
+ // We re-validate against the allowlist even though the URL comes
505
+ // from an already-trusted iframe — defense in depth.
506
+ if (event.data?.type === 'brainerce:redirect') {
507
+ const url = String((event.data as { url?: unknown }).url || '');
508
+ if (url) safePaymentRedirect(url);
509
+ return;
510
+ }
511
+ if (event.data?.type !== 'brainerce:payment-complete') return;
512
+
513
+ const params = event.data.data as Record<string, string> | undefined;
514
+ if (params?.failed === 'true') {
515
+ setError(t('paymentError'));
516
+ return;
517
+ }
518
+
519
+ // Map provider-specific params to normalized format for
520
+ // server-side verification (e.g. CardCom lowprofilecode → paymentIntentId)
521
+ const lowProfileCode = params?.lowprofilecode || params?.LowProfileCode;
522
+ const normalized: Record<string, unknown> = { ...params };
523
+ if (lowProfileCode) {
524
+ normalized.paymentIntentId = lowProfileCode;
525
+ }
526
+
527
+ // Trigger server-side verification + order creation
528
+ handleSuccess(normalized);
529
+ };
530
+ window.addEventListener('message', handleMessage);
531
+ cleanups.push(() => window.removeEventListener('message', handleMessage));
532
+ return;
533
+ }
534
+
535
+ if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
536
+
537
+ // Store for retryRender from onError callback
538
+ pendingRender = { sdk, intent };
539
+ cbRef.current.retryRender = retryRender;
540
+
541
+ // If SDK wasn't loaded from providers, load + init now
542
+ if (!sdkInitDone) {
543
+ loadScript(sdk);
544
+ // Wait for init to complete, then render once
545
+ const id = setInterval(() => {
546
+ if (sdkInitDone) {
547
+ clearInterval(id);
548
+ renderPayment(sdk, intent);
549
+ }
550
+ }, 100);
551
+ cleanups.push(() => clearInterval(id));
552
+ return;
553
+ }
554
+
555
+ // Re-init with final config if environment changed
556
+ if (sdk.initConfig?.environment && currentSdk) {
557
+ const global = (window as any)[sdk.globalName];
558
+ if (global) {
559
+ const method = sdk.initMethod || 'init';
560
+ global[method]({
561
+ ...(sdk.initConfig || {}),
562
+ events: {
563
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
564
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
565
+ onError: (r: unknown) => cbRef.current.onError(r),
566
+ onTimeout: () => cbRef.current.onTimeout(),
567
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
568
+ },
569
+ });
570
+ }
571
+ }
572
+
573
+ // SDK ready — render once
574
+ renderPayment(sdk, intent);
575
+ });
576
+
577
+ return () => cleanups.forEach((fn) => fn());
578
+ }, [checkoutId]);
579
+
580
+ // --- UI ---
581
+
582
+ // Invalid checkout id — render the error box instead of ever spinning.
583
+ // (Checked after the hooks above so hook order is stable across renders.)
584
+ if (!isValidCheckoutId(checkoutId)) {
585
+ return (
586
+ <div className={cn('border-destructive/50 rounded-md border p-4', className)}>
587
+ <p className="text-destructive text-sm">{t('paymentError')}</p>
588
+ </div>
589
+ );
590
+ }
591
+
592
+ if (loading) {
593
+ return (
594
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
595
+ <LoadingSpinner size="lg" />
596
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
597
+ </div>
598
+ );
599
+ }
600
+
601
+ if (error) {
602
+ const isNotConfigured =
603
+ error.toLowerCase().includes('not configured') ||
604
+ error.toLowerCase().includes('no payment') ||
605
+ error.toLowerCase().includes('provider');
606
+ return (
607
+ <div className={cn('py-12 text-center', className)}>
608
+ <svg
609
+ className="text-muted-foreground mx-auto mb-4 h-12 w-12"
610
+ fill="none"
611
+ viewBox="0 0 24 24"
612
+ stroke="currentColor"
613
+ >
614
+ <path
615
+ strokeLinecap="round"
616
+ strokeLinejoin="round"
617
+ strokeWidth={1.5}
618
+ 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"
619
+ />
620
+ </svg>
621
+ <h3 className="text-foreground mb-2 text-lg font-semibold">
622
+ {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
623
+ </h3>
624
+ <p className="text-muted-foreground mx-auto max-w-md text-sm">
625
+ {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
626
+ </p>
627
+ </div>
628
+ );
629
+ }
630
+
631
+ if (!paymentIntent) return null;
632
+
633
+ const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
634
+
635
+ if (sdk.renderType === 'sandbox') {
636
+ const handleCompleteSandbox = async () => {
637
+ setLoading(true);
638
+ try {
639
+ const client = getClient();
640
+ await client.completeGuestCheckout(checkoutId);
641
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
642
+ } catch (err) {
643
+ // Sandbox never declines, so there is nothing here a shopper can act on
644
+ // either.
645
+ console.error('[checkout] sandbox completion failed', err);
646
+ setError(t('paymentError'));
647
+ setLoading(false);
648
+ }
649
+ };
650
+
651
+ return (
652
+ <div className={cn('py-8 text-center', className)}>
653
+ <div className="mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-6">
654
+ <svg
655
+ className="mx-auto mb-3 h-10 w-10 text-amber-500"
656
+ fill="none"
657
+ viewBox="0 0 24 24"
658
+ stroke="currentColor"
659
+ >
660
+ <path
661
+ strokeLinecap="round"
662
+ strokeLinejoin="round"
663
+ strokeWidth={1.5}
664
+ 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"
665
+ />
666
+ </svg>
667
+ <h3 className="text-foreground mb-1 text-lg font-semibold">{t('sandboxTitle')}</h3>
668
+ <p className="text-muted-foreground mb-4 text-sm">{t('sandboxDescription')}</p>
669
+ <button
670
+ onClick={handleCompleteSandbox}
671
+ 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"
672
+ >
673
+ {t('completeTestOrder')}
674
+ </button>
675
+ </div>
676
+ </div>
677
+ );
678
+ }
679
+
680
+ if (sdk.renderType === 'sdk-widget') {
681
+ const containerId =
682
+ sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
683
+ return (
684
+ <div className={cn('py-4', className)}>
685
+ {!sdkReady && (
686
+ <div className="flex flex-col items-center justify-center py-8">
687
+ <LoadingSpinner size="lg" />
688
+ <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
689
+ </div>
690
+ )}
691
+ <div id={containerId} />
692
+ </div>
693
+ );
694
+ }
695
+
696
+ if (sdk.renderType === 'iframe') {
697
+ if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
698
+
699
+ // Detect Brainerce-hosted embed (path-based — works across localhost/
700
+ // staging/prod without a domain list) vs. a provider-hosted page. The
701
+ // embed page is already brand-styled and compact → render inline in the
702
+ // checkout flow. Provider-hosted pages carry their own branding/chrome →
703
+ // keep the modal overlay so they don't fight the checkout layout.
704
+ const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntent.clientSecret);
705
+
706
+ if (isBrainerceEmbed) {
707
+ // Inline: default to a reasonable height until the embed posts its real
708
+ // height via `brainerce:resize`. Transition smooths the resize into the
709
+ // final measurement.
710
+ const hasMeasured = embeddedIframeHeight !== null;
711
+ const iframeStyle: CSSProperties = {
712
+ height: hasMeasured ? (embeddedIframeHeight as number) : 540,
713
+ transition: hasMeasured ? 'height 0.2s ease-out' : undefined,
714
+ };
715
+ return (
716
+ <div className={cn('w-full', className)}>
717
+ <iframe
718
+ src={paymentIntentUrl}
719
+ className="block w-full border-0"
720
+ style={iframeStyle}
721
+ title={t('payment')}
722
+ allow="payment"
723
+ />
724
+ </div>
725
+ );
726
+ }
727
+
728
+ // Closed by the shopper (X, browser Back, or arriving with
729
+ // `?canceled=true`): hold the payment step with a way back in. The intent
730
+ // is kept; re-opening reuses it (the core hands back the same intent for
731
+ // an unchanged charge).
732
+ if (modalDismissed) {
733
+ return (
734
+ <div
735
+ className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
736
+ >
737
+ <p className="text-muted-foreground mb-4 text-sm">{t('paymentModalClosed')}</p>
738
+ <button
739
+ type="button"
740
+ onClick={() => setModalDismissed(false)}
741
+ className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-6 py-2.5 text-sm font-medium transition-colors"
742
+ >
743
+ {t('openPaymentModal')}
744
+ </button>
745
+ </div>
746
+ );
747
+ }
748
+
749
+ // Provider-hosted page (e.g. Cardcom LowProfile with full merchant
750
+ // branding) — modal overlay keeps it visually contained.
751
+ const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
752
+ currency: paymentIntent.currency,
753
+ }) as string;
754
+ const iframeStyle: CSSProperties = { height: '90vh', minHeight: 700 };
755
+ return (
756
+ <>
757
+ {/* Modal overlay */}
758
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
759
+ <div className="bg-background relative mx-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl shadow-2xl">
760
+ {/* Header */}
761
+ <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-4">
762
+ <div className="flex min-w-0 flex-col">
763
+ <span className="text-foreground truncate text-sm font-semibold">
764
+ {storeInfo?.name}
765
+ </span>
766
+ <span className="text-muted-foreground text-xs">{t('payment')}</span>
767
+ </div>
768
+ <div className="flex items-baseline gap-1.5">
769
+ <span className="text-foreground text-lg font-bold tabular-nums">
770
+ {formattedAmount}
771
+ </span>
772
+ <span className="text-muted-foreground text-xs uppercase">
773
+ {paymentIntent.currency}
774
+ </span>
775
+ </div>
776
+ <button
777
+ type="button"
778
+ onClick={closeProviderModal}
779
+ className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
780
+ aria-label="Close"
781
+ >
782
+ <svg
783
+ width="14"
784
+ height="14"
785
+ viewBox="0 0 14 14"
786
+ fill="none"
787
+ stroke="currentColor"
788
+ strokeWidth="2"
789
+ strokeLinecap="round"
790
+ >
791
+ <path d="M1 1l12 12M13 1L1 13" />
792
+ </svg>
793
+ </button>
794
+ </div>
795
+ {/* Iframe body */}
796
+ <iframe
797
+ src={paymentIntentUrl}
798
+ className="w-full border-0"
799
+ style={iframeStyle}
800
+ title={t('payment')}
801
+ allow="payment"
802
+ />
803
+ {/* Footer */}
804
+ <div className="border-border bg-secondary/30 text-muted-foreground flex items-center justify-center gap-2 border-t px-5 py-3 text-xs">
805
+ <svg
806
+ width="14"
807
+ height="14"
808
+ viewBox="0 0 24 24"
809
+ fill="none"
810
+ stroke="currentColor"
811
+ strokeWidth="2"
812
+ strokeLinecap="round"
813
+ strokeLinejoin="round"
814
+ aria-hidden="true"
815
+ >
816
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
817
+ <path d="m9 12 2 2 4-4" />
818
+ </svg>
819
+ <span>
820
+ {t('securePayment')} · <span className="font-medium">Brainerce</span>
821
+ </span>
822
+ </div>
823
+ </div>
824
+ </div>
825
+ {/* Placeholder so the checkout layout doesn't collapse */}
826
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
827
+ <LoadingSpinner size="lg" />
828
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
829
+ </div>
830
+ </>
831
+ );
832
+ }
833
+
834
+ return (
835
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
836
+ <LoadingSpinner size="lg" />
837
+ <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
838
+ <p className="text-muted-foreground mt-2 text-xs">
839
+ {t('redirectingHint')}
840
+ <a href={paymentIntentUrl} className="text-primary hover:underline">
841
+ {t('clickHere')}
842
+ </a>
843
+ .
844
+ </p>
845
+ </div>
846
+ );
847
+ }