create-brainerce-store 1.28.19 → 1.28.21

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.
@@ -1,656 +1,730 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useRef, useCallback } from 'react';
4
- import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
5
- import { formatPrice } from 'brainerce';
6
- import { getClient } from '@/lib/brainerce';
7
- import { useTranslations } from '@/lib/translations';
8
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
9
- import { useStoreInfo } from '@/providers/store-provider';
10
- import { cn } from '@/lib/utils';
11
- import { isAllowedPaymentUrl, isValidCheckoutId, safePaymentRedirect } from '@/lib/safe-redirect';
12
-
13
- /**
14
- * Backward-compat defaults when backend doesn't return clientSdk.
15
- */
16
- const LEGACY_GROW_SDK: PaymentClientSdk = {
17
- renderType: 'sdk-widget',
18
- scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
19
- globalName: 'growPayment',
20
- initMethod: 'init',
21
- renderMethod: 'renderPaymentOptions',
22
- containerId: 'grow-payment-container',
23
- initConfig: { version: 1, environment: 'DEV' },
24
- additionalScripts: [
25
- { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
26
- ],
27
- bodyStyles:
28
- '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
29
- };
30
-
31
- interface PaymentStepProps {
32
- checkoutId: string;
33
- className?: string;
34
- }
35
-
36
- function resolveClientSdk(
37
- intent: PaymentIntent | null,
38
- preloadedSdk?: PaymentClientSdk | null
39
- ): PaymentClientSdk {
40
- const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
41
- const runtimeSdk = intent?.clientSdk;
42
- if (fullSdk) {
43
- if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
44
- return {
45
- ...fullSdk,
46
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
47
- ...(runtimeSdk.initConfig
48
- ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
49
- : {}),
50
- };
51
- }
52
- const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
53
- if (legacy && runtimeSdk) {
54
- return {
55
- ...legacy,
56
- ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
57
- ...(runtimeSdk.initConfig
58
- ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
59
- : {}),
60
- };
61
- }
62
- if (legacy) return legacy;
63
- return { renderType: 'redirect' };
64
- }
65
-
66
- function extractMessage(response: unknown): string {
67
- if (typeof response === 'string') return response;
68
- return (response as { message?: string })?.message || '';
69
- }
70
-
71
- export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
72
- const t = useTranslations('checkout');
73
- const { storeInfo } = useStoreInfo();
74
-
75
- // Defense in depth: the parent already validates checkoutId from URL params,
76
- // but we re-check here so the component is safe to render in any context.
77
- if (!isValidCheckoutId(checkoutId)) {
78
- return (
79
- <div className={cn('border-destructive/50 rounded-md border p-4', className)}>
80
- <p className="text-destructive text-sm">{t('paymentError')}</p>
81
- </div>
82
- );
83
- }
84
-
85
- const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
86
- const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
87
- const [loading, setLoading] = useState(true);
88
- const [error, setError] = useState<string | null>(null);
89
- const [sdkReady, setSdkReady] = useState(false);
90
- const walletOpenRef = useRef(false);
91
- const initialized = useRef(false);
92
-
93
- // Stable refs for SDK event callbacks (avoids stale closures in onload)
94
- const cbRef = useRef({
95
- onSuccess: (_r: unknown) => {},
96
- onFailure: (_r: unknown) => {},
97
- onError: (_r: unknown) => {},
98
- onTimeout: () => {},
99
- onWalletChange: (_s: string) => {},
100
- retryRender: () => {},
101
- });
102
-
103
- const handleSuccess = useCallback(
104
- async (response: unknown) => {
105
- console.info('Payment SDK success:', JSON.stringify(response));
106
- try {
107
- const client = getClient();
108
- const resp = response as Record<string, unknown>;
109
- const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
110
- | Record<string, unknown>
111
- | undefined;
112
- await client.confirmSdkPayment(checkoutId, data || undefined);
113
- } catch (err) {
114
- console.warn('Failed to confirm payment with backend:', err);
115
- }
116
- window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
117
- },
118
- [checkoutId]
119
- );
120
-
121
- cbRef.current = {
122
- onSuccess: handleSuccess,
123
- onFailure: (response: unknown) => {
124
- console.error('Payment SDK failure:', response);
125
- setError(extractMessage(response) || t('paymentError'));
126
- },
127
- onError: (response: unknown) => {
128
- const TRANSIENT = [
129
- 'Wallet not initialized',
130
- "SDK was not loaded as needed and therefore can't run",
131
- ];
132
- const msg = extractMessage(response);
133
- if (TRANSIENT.some((e) => msg.includes(e))) {
134
- console.info('Payment SDK: transient error, retrying render in 1s:', msg);
135
- setTimeout(() => cbRef.current.retryRender(), 1000);
136
- return;
137
- }
138
- console.error('Payment SDK error:', response);
139
- setError(msg || t('paymentError'));
140
- },
141
- onTimeout: () => {
142
- console.warn('Payment SDK: wallet timed out');
143
- setError(t('paymentTimedOut'));
144
- },
145
- onWalletChange: (state: string) => {
146
- console.info('Payment SDK wallet state:', state);
147
- if (state === 'open') {
148
- walletOpenRef.current = true;
149
- setSdkReady(true);
150
- }
151
- if (state === 'close') setSdkReady(false);
152
- },
153
- retryRender: () => {},
154
- };
155
-
156
- // =========================================================================
157
- // MAIN EFFECT — Follows Grow SDK docs exactly:
158
- //
159
- // Step 1: Load gs.min.js (insertBefore, as docs show)
160
- // Step 2: s.onload → growPayment.init({ environment, version, events })
161
- // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
162
- // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
163
- // Step 4: growPayment.renderPaymentOptions(authCode)
164
- //
165
- // "call createPaymentProcess right before you need to render the wallet"
166
- // =========================================================================
167
- useEffect(() => {
168
- if (initialized.current) return;
169
- initialized.current = true;
170
-
171
- const client = getClient();
172
- const iframeSuccessUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}`;
173
- const iframeFailedUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}&failed=true`;
174
- const redirectSuccessUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
175
- const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
176
-
177
- let sdkInitDone = false;
178
- let currentSdk: PaymentClientSdk | null = null;
179
- const cleanups: (() => void)[] = [];
180
-
181
- // --- Load SDK script exactly as Grow docs show ---
182
- function loadScript(sdk: PaymentClientSdk) {
183
- if (!sdk.scriptUrl || !sdk.globalName) return;
184
-
185
- // Inject bodyStyles
186
- if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
187
- const style = document.createElement('style');
188
- style.setAttribute('data-payment-sdk', 'true');
189
- style.textContent = sdk.bodyStyles;
190
- document.head.appendChild(style);
191
- cleanups.push(() => style.remove());
192
- }
193
-
194
- // Additional scripts (Apple Pay etc.) — fire and forget
195
- if (sdk.additionalScripts) {
196
- for (const extra of sdk.additionalScripts) {
197
- if (document.querySelector(`script[src="${extra.url}"]`)) continue;
198
- const s = document.createElement('script');
199
- s.type = 'text/javascript';
200
- s.async = true;
201
- s.src = extra.url;
202
- const ref = document.getElementsByTagName('script')[0];
203
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
204
- else document.head.appendChild(s);
205
- }
206
- }
207
-
208
- // Already loaded? Init immediately
209
- if ((window as any)[sdk.globalName]) {
210
- initSdk(sdk);
211
- return;
212
- }
213
-
214
- // Already loading (from a previous call)? Wait for it instead of duplicating
215
- if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
216
- const waitId = setInterval(() => {
217
- if ((window as any)[sdk.globalName!]) {
218
- clearInterval(waitId);
219
- initSdk(sdk);
220
- }
221
- }, 100);
222
- cleanups.push(() => clearInterval(waitId));
223
- return;
224
- }
225
-
226
- // Load main SDK — insertBefore first <script> as Grow docs show
227
- const s = document.createElement('script');
228
- s.type = 'text/javascript';
229
- s.async = true;
230
- s.src = sdk.scriptUrl;
231
- s.onload = () => initSdk(sdk); // init DIRECTLY in onload
232
- s.onerror = () => {
233
- console.error('Payment SDK: script load failed');
234
- setError(t('failedToLoadPaymentSdk'));
235
- };
236
- const ref = document.getElementsByTagName('script')[0];
237
- if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
238
- else document.head.appendChild(s);
239
- }
240
-
241
- // --- Init: called in s.onload (as Grow docs require) ---
242
- function initSdk(sdk: PaymentClientSdk) {
243
- if (sdkInitDone) return; // Guard against double init
244
-
245
- const global = (window as any)[sdk.globalName!];
246
- if (!global) {
247
- setError(t('failedToLoadPaymentSdk'));
248
- return;
249
- }
250
-
251
- const method = sdk.initMethod || 'init';
252
- const config = {
253
- ...(sdk.initConfig || {}),
254
- events: {
255
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
256
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
257
- onError: (r: unknown) => cbRef.current.onError(r),
258
- onTimeout: () => cbRef.current.onTimeout(),
259
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
260
- },
261
- };
262
-
263
- console.info(`Payment SDK: calling ${method}()`);
264
- global[method](config);
265
- sdkInitDone = true;
266
- }
267
-
268
- // --- Render: call once, then safety-net retries if wallet doesn't open ---
269
- // Grow SDK sometimes silently swallows renderPaymentOptions when its
270
- // internal resources (mp.min.js etc.) aren't fully loaded yet.
271
- // Strategy: render once, then retry up to 3 times with increasing delays
272
- // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
273
- let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
274
- let renderAttempts = 0;
275
- const MAX_RENDER_ATTEMPTS = 4;
276
-
277
- function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
278
- const global = (window as any)[sdk.globalName!];
279
- if (!global || walletOpenRef.current) return;
280
-
281
- const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
282
- const renderArg = sdk.renderArg || intent.clientSecret;
283
- renderAttempts++;
284
-
285
- try {
286
- global[renderMethod](renderArg);
287
- console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
288
- } catch (err) {
289
- console.info('Payment SDK: render threw, will retry in 1s');
290
- }
291
-
292
- // Safety net: if wallet doesn't open within a delay, retry
293
- if (renderAttempts < MAX_RENDER_ATTEMPTS) {
294
- const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
295
- const retryId = setTimeout(() => {
296
- if (!walletOpenRef.current) {
297
- console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
298
- renderPayment(sdk, intent);
299
- }
300
- }, delay);
301
- cleanups.push(() => clearTimeout(retryId));
302
- }
303
- }
304
-
305
- function retryRender() {
306
- if (pendingRender && !walletOpenRef.current) {
307
- renderPayment(pendingRender.sdk, pendingRender.intent);
308
- }
309
- }
310
-
311
- // =============================================
312
- // Execution flow
313
- // =============================================
314
-
315
- // A) Get SDK config from providers (fast, no wallet timer)
316
- const providerPromise = client
317
- .getPaymentProviders()
318
- .then((res) => {
319
- const sdk = res.defaultProvider?.clientSdk;
320
- if (sdk) setPreloadedSdk(sdk);
321
- return sdk || null;
322
- })
323
- .catch(() => null);
324
-
325
- // B) Load + init SDK as early as possible (skip for sandbox)
326
- providerPromise.then((providerSdk) => {
327
- if (providerSdk?.renderType === 'sandbox') return;
328
- if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
329
- currentSdk = providerSdk;
330
- loadScript(providerSdk);
331
- }
332
- });
333
-
334
- // C) Create payment intent (starts wallet timer)
335
- // Wait for provider info so we can choose the right success URL:
336
- // iframe providers redirect inside the iframe to /payment-complete (postMessage),
337
- // redirect providers go straight to /order-confirmation.
338
- const intentPromise = providerPromise
339
- .then((providerSdk) => {
340
- const isIframe = providerSdk?.renderType === 'iframe';
341
- const successUrl = isIframe ? iframeSuccessUrl : redirectSuccessUrl;
342
- const failedUrl = isIframe ? iframeFailedUrl : cancelUrl;
343
- return client.createPaymentIntent(checkoutId, {
344
- successUrl,
345
- cancelUrl: failedUrl,
346
- });
347
- })
348
- .then((intent) => {
349
- setPaymentIntent(intent);
350
- return intent;
351
- })
352
- .catch((err) => {
353
- setError(err instanceof Error ? err.message : t('paymentError'));
354
- return null;
355
- })
356
- .finally(() => setLoading(false));
357
-
358
- // D) When both ready: resolve final SDK config and render
359
- Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
360
- if (!intent) return;
361
-
362
- const sdk = resolveClientSdk(intent, providerSdk);
363
- currentSdk = sdk;
364
-
365
- // Sandbox mode — no SDK to load, UI handles it
366
- if (sdk.renderType === 'sandbox') return;
367
-
368
- if (sdk.renderType === 'redirect') {
369
- if (!isAllowedPaymentUrl(intent.clientSecret)) {
370
- setError(t('paymentRedirectBlocked'));
371
- return;
372
- }
373
- safePaymentRedirect(intent.clientSecret);
374
- return;
375
- }
376
-
377
- // Iframe mode: listen for postMessage from the /payment-complete callback
378
- // page that loads inside the iframe after the provider redirects on completion.
379
- if (sdk.renderType === 'iframe') {
380
- if (!isAllowedPaymentUrl(intent.clientSecret)) {
381
- setError(t('paymentRedirectBlocked'));
382
- return;
383
- }
384
- const handleMessage = (event: MessageEvent) => {
385
- if (event.origin !== window.location.origin) return;
386
- if (event.data?.type !== 'brainerce:payment-complete') return;
387
-
388
- const params = event.data.data as Record<string, string> | undefined;
389
- if (params?.failed === 'true') {
390
- setError(t('paymentError'));
391
- return;
392
- }
393
-
394
- // Map provider-specific params to normalized format for
395
- // server-side verification (e.g. CardCom lowprofilecode → paymentIntentId)
396
- const lowProfileCode = params?.lowprofilecode || params?.LowProfileCode;
397
- const normalized: Record<string, unknown> = { ...params };
398
- if (lowProfileCode) {
399
- normalized.paymentIntentId = lowProfileCode;
400
- }
401
-
402
- // Trigger server-side verification + order creation
403
- handleSuccess(normalized);
404
- };
405
- window.addEventListener('message', handleMessage);
406
- cleanups.push(() => window.removeEventListener('message', handleMessage));
407
- return;
408
- }
409
-
410
- if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
411
-
412
- // Store for retryRender from onError callback
413
- pendingRender = { sdk, intent };
414
- cbRef.current.retryRender = retryRender;
415
-
416
- // If SDK wasn't loaded from providers, load + init now
417
- if (!sdkInitDone) {
418
- loadScript(sdk);
419
- // Wait for init to complete, then render once
420
- const id = setInterval(() => {
421
- if (sdkInitDone) {
422
- clearInterval(id);
423
- renderPayment(sdk, intent);
424
- }
425
- }, 100);
426
- cleanups.push(() => clearInterval(id));
427
- return;
428
- }
429
-
430
- // Re-init with final config if environment changed
431
- if (sdk.initConfig?.environment && currentSdk) {
432
- const global = (window as any)[sdk.globalName];
433
- if (global) {
434
- const method = sdk.initMethod || 'init';
435
- global[method]({
436
- ...(sdk.initConfig || {}),
437
- events: {
438
- onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
439
- onFailure: (r: unknown) => cbRef.current.onFailure(r),
440
- onError: (r: unknown) => cbRef.current.onError(r),
441
- onTimeout: () => cbRef.current.onTimeout(),
442
- onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
443
- },
444
- });
445
- }
446
- }
447
-
448
- // SDK ready — render once
449
- renderPayment(sdk, intent);
450
- });
451
-
452
- return () => cleanups.forEach((fn) => fn());
453
- }, [checkoutId]);
454
-
455
- // --- UI ---
456
-
457
- if (loading) {
458
- return (
459
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
460
- <LoadingSpinner size="lg" />
461
- <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
462
- </div>
463
- );
464
- }
465
-
466
- if (error) {
467
- const isNotConfigured =
468
- error.toLowerCase().includes('not configured') ||
469
- error.toLowerCase().includes('no payment') ||
470
- error.toLowerCase().includes('provider');
471
- return (
472
- <div className={cn('py-12 text-center', className)}>
473
- <svg
474
- className="text-muted-foreground mx-auto mb-4 h-12 w-12"
475
- fill="none"
476
- viewBox="0 0 24 24"
477
- stroke="currentColor"
478
- >
479
- <path
480
- strokeLinecap="round"
481
- strokeLinejoin="round"
482
- strokeWidth={1.5}
483
- 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"
484
- />
485
- </svg>
486
- <h3 className="text-foreground mb-2 text-lg font-semibold">
487
- {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
488
- </h3>
489
- <p className="text-muted-foreground mx-auto max-w-md text-sm">
490
- {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
491
- </p>
492
- </div>
493
- );
494
- }
495
-
496
- if (!paymentIntent) return null;
497
-
498
- const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
499
-
500
- if (sdk.renderType === 'sandbox') {
501
- const handleCompleteSandbox = async () => {
502
- setLoading(true);
503
- try {
504
- const client = getClient();
505
- await client.completeGuestCheckout(checkoutId);
506
- window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
507
- } catch (err) {
508
- setError(err instanceof Error ? err.message : t('paymentError'));
509
- setLoading(false);
510
- }
511
- };
512
-
513
- return (
514
- <div className={cn('py-8 text-center', className)}>
515
- <div className="mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-6">
516
- <svg
517
- className="mx-auto mb-3 h-10 w-10 text-amber-500"
518
- fill="none"
519
- viewBox="0 0 24 24"
520
- stroke="currentColor"
521
- >
522
- <path
523
- strokeLinecap="round"
524
- strokeLinejoin="round"
525
- strokeWidth={1.5}
526
- 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"
527
- />
528
- </svg>
529
- <h3 className="text-foreground mb-1 text-lg font-semibold">{t('sandboxTitle')}</h3>
530
- <p className="text-muted-foreground mb-4 text-sm">{t('sandboxDescription')}</p>
531
- <button
532
- onClick={handleCompleteSandbox}
533
- 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"
534
- >
535
- {t('completeTestOrder')}
536
- </button>
537
- </div>
538
- </div>
539
- );
540
- }
541
-
542
- if (sdk.renderType === 'sdk-widget') {
543
- const containerId =
544
- sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
545
- return (
546
- <div className={cn('py-4', className)}>
547
- {!sdkReady && (
548
- <div className="flex flex-col items-center justify-center py-8">
549
- <LoadingSpinner size="lg" />
550
- <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
551
- </div>
552
- )}
553
- <div id={containerId} />
554
- </div>
555
- );
556
- }
557
-
558
- if (sdk.renderType === 'iframe') {
559
- if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
560
- const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
561
- currency: paymentIntent.currency,
562
- }) as string;
563
- return (
564
- <>
565
- {/* Modal overlay */}
566
- <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
567
- <div className="bg-background relative mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl shadow-2xl">
568
- {/* Header */}
569
- <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-4">
570
- <div className="flex min-w-0 flex-col">
571
- <span className="text-foreground truncate text-sm font-semibold">
572
- {storeInfo?.name}
573
- </span>
574
- <span className="text-muted-foreground text-xs">{t('payment')}</span>
575
- </div>
576
- <div className="flex items-baseline gap-1.5">
577
- <span className="text-foreground text-lg font-bold tabular-nums">
578
- {formattedAmount}
579
- </span>
580
- <span className="text-muted-foreground text-xs uppercase">
581
- {paymentIntent.currency}
582
- </span>
583
- </div>
584
- <button
585
- onClick={() => {
586
- window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
587
- }}
588
- 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"
589
- aria-label="Close"
590
- >
591
- <svg
592
- width="14"
593
- height="14"
594
- viewBox="0 0 14 14"
595
- fill="none"
596
- stroke="currentColor"
597
- strokeWidth="2"
598
- strokeLinecap="round"
599
- >
600
- <path d="M1 1l12 12M13 1L1 13" />
601
- </svg>
602
- </button>
603
- </div>
604
- {/* Iframe body */}
605
- <iframe
606
- src={paymentIntent.clientSecret}
607
- className="w-full border-0"
608
- style={{ height: '80vh' }}
609
- title={t('payment')}
610
- allow="payment"
611
- />
612
- {/* Footer */}
613
- <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">
614
- <svg
615
- width="14"
616
- height="14"
617
- viewBox="0 0 24 24"
618
- fill="none"
619
- stroke="currentColor"
620
- strokeWidth="2"
621
- strokeLinecap="round"
622
- strokeLinejoin="round"
623
- aria-hidden="true"
624
- >
625
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
626
- <path d="m9 12 2 2 4-4" />
627
- </svg>
628
- <span>
629
- {t('securePayment')} · <span className="font-medium">Brainerce</span>
630
- </span>
631
- </div>
632
- </div>
633
- </div>
634
- {/* Placeholder so the checkout layout doesn't collapse */}
635
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
636
- <LoadingSpinner size="lg" />
637
- <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
638
- </div>
639
- </>
640
- );
641
- }
642
-
643
- return (
644
- <div className={cn('flex flex-col items-center justify-center py-12', className)}>
645
- <LoadingSpinner size="lg" />
646
- <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
647
- <p className="text-muted-foreground mt-2 text-xs">
648
- {t('redirectingHint')}
649
- <a href={paymentIntent.clientSecret} className="text-primary hover:underline">
650
- {t('clickHere')}
651
- </a>
652
- .
653
- </p>
654
- </div>
655
- );
656
- }
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 { getClient } from '@/lib/brainerce';
7
+ import { useTranslations } from '@/lib/translations';
8
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
9
+ import { useStoreInfo } from '@/providers/store-provider';
10
+ import { cn } from '@/lib/utils';
11
+ import { isAllowedPaymentUrl, isValidCheckoutId, safePaymentRedirect } from '@/lib/safe-redirect';
12
+
13
+ /**
14
+ * Backward-compat defaults when backend doesn't return clientSdk.
15
+ */
16
+ const LEGACY_GROW_SDK: PaymentClientSdk = {
17
+ renderType: 'sdk-widget',
18
+ scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
19
+ globalName: 'growPayment',
20
+ initMethod: 'init',
21
+ renderMethod: 'renderPaymentOptions',
22
+ containerId: 'grow-payment-container',
23
+ initConfig: { version: 1, environment: 'DEV' },
24
+ additionalScripts: [
25
+ { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
26
+ ],
27
+ bodyStyles:
28
+ '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
29
+ };
30
+
31
+ interface PaymentStepProps {
32
+ checkoutId: string;
33
+ className?: string;
34
+ }
35
+
36
+ function resolveClientSdk(
37
+ intent: PaymentIntent | null,
38
+ preloadedSdk?: PaymentClientSdk | null
39
+ ): PaymentClientSdk {
40
+ // Runtime SDK (returned by the payment app in the intent) wins over the
41
+ // preloaded manifest SDK. This lets a provider return a different renderType
42
+ // per-installation (e.g. Cardcom returning 'embedded-fields' when the
43
+ // merchant opts in, while the manifest default stays 'iframe').
44
+ const fullSdk = [intent?.clientSdk, preloadedSdk].find((s) => s?.renderType);
45
+ const runtimeSdk = intent?.clientSdk;
46
+ if (fullSdk) {
47
+ if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
48
+ return {
49
+ ...fullSdk,
50
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
51
+ ...(runtimeSdk.initConfig
52
+ ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
53
+ : {}),
54
+ };
55
+ }
56
+ const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
57
+ if (legacy && runtimeSdk) {
58
+ return {
59
+ ...legacy,
60
+ ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
61
+ ...(runtimeSdk.initConfig
62
+ ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
63
+ : {}),
64
+ };
65
+ }
66
+ if (legacy) return legacy;
67
+ return { renderType: 'redirect' };
68
+ }
69
+
70
+ function extractMessage(response: unknown): string {
71
+ if (typeof response === 'string') return response;
72
+ return (response as { message?: string })?.message || '';
73
+ }
74
+
75
+ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
76
+ const t = useTranslations('checkout');
77
+ const { storeInfo } = useStoreInfo();
78
+
79
+ // Defense in depth: the parent already validates checkoutId from URL params,
80
+ // but we re-check here so the component is safe to render in any context.
81
+ if (!isValidCheckoutId(checkoutId)) {
82
+ return (
83
+ <div className={cn('border-destructive/50 rounded-md border p-4', className)}>
84
+ <p className="text-destructive text-sm">{t('paymentError')}</p>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
90
+ const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
91
+ const [loading, setLoading] = useState(true);
92
+ const [error, setError] = useState<string | null>(null);
93
+ const [sdkReady, setSdkReady] = useState(false);
94
+ // Set by the Cardcom OpenFields embed page via `brainerce:resize` postMessage.
95
+ // Presence of this value is how we distinguish our own compact embed page
96
+ // from a provider's hosted page — used to narrow the modal + auto-size the
97
+ // iframe instead of reserving the tall LowProfile footprint.
98
+ const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
99
+ const walletOpenRef = useRef(false);
100
+ const initialized = useRef(false);
101
+
102
+ // Stable refs for SDK event callbacks (avoids stale closures in onload)
103
+ const cbRef = useRef({
104
+ onSuccess: (_r: unknown) => {},
105
+ onFailure: (_r: unknown) => {},
106
+ onError: (_r: unknown) => {},
107
+ onTimeout: () => {},
108
+ onWalletChange: (_s: string) => {},
109
+ retryRender: () => {},
110
+ });
111
+
112
+ const handleSuccess = useCallback(
113
+ async (response: unknown) => {
114
+ console.info('Payment SDK success:', JSON.stringify(response));
115
+ try {
116
+ const client = getClient();
117
+ const resp = response as Record<string, unknown>;
118
+ const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
119
+ | Record<string, unknown>
120
+ | undefined;
121
+ await client.confirmSdkPayment(checkoutId, data || undefined);
122
+ } catch (err) {
123
+ console.warn('Failed to confirm payment with backend:', err);
124
+ }
125
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
126
+ },
127
+ [checkoutId]
128
+ );
129
+
130
+ cbRef.current = {
131
+ onSuccess: handleSuccess,
132
+ onFailure: (response: unknown) => {
133
+ console.error('Payment SDK failure:', response);
134
+ setError(extractMessage(response) || t('paymentError'));
135
+ },
136
+ onError: (response: unknown) => {
137
+ const TRANSIENT = [
138
+ 'Wallet not initialized',
139
+ "SDK was not loaded as needed and therefore can't run",
140
+ ];
141
+ const msg = extractMessage(response);
142
+ if (TRANSIENT.some((e) => msg.includes(e))) {
143
+ console.info('Payment SDK: transient error, retrying render in 1s:', msg);
144
+ setTimeout(() => cbRef.current.retryRender(), 1000);
145
+ return;
146
+ }
147
+ console.error('Payment SDK error:', response);
148
+ setError(msg || t('paymentError'));
149
+ },
150
+ onTimeout: () => {
151
+ console.warn('Payment SDK: wallet timed out');
152
+ setError(t('paymentTimedOut'));
153
+ },
154
+ onWalletChange: (state: string) => {
155
+ console.info('Payment SDK wallet state:', state);
156
+ if (state === 'open') {
157
+ walletOpenRef.current = true;
158
+ setSdkReady(true);
159
+ }
160
+ if (state === 'close') setSdkReady(false);
161
+ },
162
+ retryRender: () => {},
163
+ };
164
+
165
+ // =========================================================================
166
+ // MAIN EFFECT — Follows Grow SDK docs exactly:
167
+ //
168
+ // Step 1: Load gs.min.js (insertBefore, as docs show)
169
+ // Step 2: s.onload growPayment.init({ environment, version, events })
170
+ // This triggers the SDK to load mp.min.js → CSS, HTML, params, services
171
+ // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init)
172
+ // Step 4: growPayment.renderPaymentOptions(authCode)
173
+ //
174
+ // "call createPaymentProcess right before you need to render the wallet"
175
+ // =========================================================================
176
+ useEffect(() => {
177
+ if (initialized.current) return;
178
+ initialized.current = true;
179
+
180
+ const client = getClient();
181
+ const iframeSuccessUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}`;
182
+ const iframeFailedUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}&failed=true`;
183
+ const redirectSuccessUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
184
+ const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
185
+
186
+ let sdkInitDone = false;
187
+ let currentSdk: PaymentClientSdk | null = null;
188
+ const cleanups: (() => void)[] = [];
189
+
190
+ // --- Load SDK script exactly as Grow docs show ---
191
+ function loadScript(sdk: PaymentClientSdk) {
192
+ if (!sdk.scriptUrl || !sdk.globalName) return;
193
+
194
+ // Inject bodyStyles
195
+ if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
196
+ const style = document.createElement('style');
197
+ style.setAttribute('data-payment-sdk', 'true');
198
+ style.textContent = sdk.bodyStyles;
199
+ document.head.appendChild(style);
200
+ cleanups.push(() => style.remove());
201
+ }
202
+
203
+ // Additional scripts (Apple Pay etc.) — fire and forget
204
+ if (sdk.additionalScripts) {
205
+ for (const extra of sdk.additionalScripts) {
206
+ if (document.querySelector(`script[src="${extra.url}"]`)) continue;
207
+ const s = document.createElement('script');
208
+ s.type = 'text/javascript';
209
+ s.async = true;
210
+ s.src = extra.url;
211
+ const ref = document.getElementsByTagName('script')[0];
212
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
213
+ else document.head.appendChild(s);
214
+ }
215
+ }
216
+
217
+ // Already loaded? Init immediately
218
+ if ((window as any)[sdk.globalName]) {
219
+ initSdk(sdk);
220
+ return;
221
+ }
222
+
223
+ // Already loading (from a previous call)? Wait for it instead of duplicating
224
+ if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) {
225
+ const waitId = setInterval(() => {
226
+ if ((window as any)[sdk.globalName!]) {
227
+ clearInterval(waitId);
228
+ initSdk(sdk);
229
+ }
230
+ }, 100);
231
+ cleanups.push(() => clearInterval(waitId));
232
+ return;
233
+ }
234
+
235
+ // Load main SDK — insertBefore first <script> as Grow docs show
236
+ const s = document.createElement('script');
237
+ s.type = 'text/javascript';
238
+ s.async = true;
239
+ s.src = sdk.scriptUrl;
240
+ s.onload = () => initSdk(sdk); // init DIRECTLY in onload
241
+ s.onerror = () => {
242
+ console.error('Payment SDK: script load failed');
243
+ setError(t('failedToLoadPaymentSdk'));
244
+ };
245
+ const ref = document.getElementsByTagName('script')[0];
246
+ if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
247
+ else document.head.appendChild(s);
248
+ }
249
+
250
+ // --- Init: called in s.onload (as Grow docs require) ---
251
+ function initSdk(sdk: PaymentClientSdk) {
252
+ if (sdkInitDone) return; // Guard against double init
253
+
254
+ const global = (window as any)[sdk.globalName!];
255
+ if (!global) {
256
+ setError(t('failedToLoadPaymentSdk'));
257
+ return;
258
+ }
259
+
260
+ const method = sdk.initMethod || 'init';
261
+ const config = {
262
+ ...(sdk.initConfig || {}),
263
+ events: {
264
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
265
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
266
+ onError: (r: unknown) => cbRef.current.onError(r),
267
+ onTimeout: () => cbRef.current.onTimeout(),
268
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
269
+ },
270
+ };
271
+
272
+ console.info(`Payment SDK: calling ${method}()`);
273
+ global[method](config);
274
+ sdkInitDone = true;
275
+ }
276
+
277
+ // --- Render: call once, then safety-net retries if wallet doesn't open ---
278
+ // Grow SDK sometimes silently swallows renderPaymentOptions when its
279
+ // internal resources (mp.min.js etc.) aren't fully loaded yet.
280
+ // Strategy: render once, then retry up to 3 times with increasing delays
281
+ // (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
282
+ let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
283
+ let renderAttempts = 0;
284
+ const MAX_RENDER_ATTEMPTS = 4;
285
+
286
+ function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
287
+ const global = (window as any)[sdk.globalName!];
288
+ if (!global || walletOpenRef.current) return;
289
+
290
+ const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
291
+ const renderArg = sdk.renderArg || intent.clientSecret;
292
+ renderAttempts++;
293
+
294
+ try {
295
+ global[renderMethod](renderArg);
296
+ console.info(`Payment SDK: renderPaymentOptions called (attempt ${renderAttempts})`);
297
+ } catch (err) {
298
+ console.info('Payment SDK: render threw, will retry in 1s');
299
+ }
300
+
301
+ // Safety net: if wallet doesn't open within a delay, retry
302
+ if (renderAttempts < MAX_RENDER_ATTEMPTS) {
303
+ const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
304
+ const retryId = setTimeout(() => {
305
+ if (!walletOpenRef.current) {
306
+ console.info(`Payment SDK: wallet not open after ${delay}ms, retrying render...`);
307
+ renderPayment(sdk, intent);
308
+ }
309
+ }, delay);
310
+ cleanups.push(() => clearTimeout(retryId));
311
+ }
312
+ }
313
+
314
+ function retryRender() {
315
+ if (pendingRender && !walletOpenRef.current) {
316
+ renderPayment(pendingRender.sdk, pendingRender.intent);
317
+ }
318
+ }
319
+
320
+ // =============================================
321
+ // Execution flow
322
+ // =============================================
323
+
324
+ // A) Get SDK config from providers (fast, no wallet timer)
325
+ const providerPromise = client
326
+ .getPaymentProviders()
327
+ .then((res) => {
328
+ const sdk = res.defaultProvider?.clientSdk;
329
+ if (sdk) setPreloadedSdk(sdk);
330
+ return sdk || null;
331
+ })
332
+ .catch(() => null);
333
+
334
+ // B) Load + init SDK as early as possible (skip for sandbox)
335
+ providerPromise.then((providerSdk) => {
336
+ if (providerSdk?.renderType === 'sandbox') return;
337
+ if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
338
+ currentSdk = providerSdk;
339
+ loadScript(providerSdk);
340
+ }
341
+ });
342
+
343
+ // C) Create payment intent (starts wallet timer)
344
+ // Wait for provider info so we can choose the right success URL:
345
+ // iframe providers redirect inside the iframe to /payment-complete (postMessage),
346
+ // redirect providers go straight to /order-confirmation.
347
+ const intentPromise = providerPromise
348
+ .then((providerSdk) => {
349
+ const isIframe = providerSdk?.renderType === 'iframe';
350
+ const successUrl = isIframe ? iframeSuccessUrl : redirectSuccessUrl;
351
+ const failedUrl = isIframe ? iframeFailedUrl : cancelUrl;
352
+ return client.createPaymentIntent(checkoutId, {
353
+ successUrl,
354
+ cancelUrl: failedUrl,
355
+ });
356
+ })
357
+ .then((intent) => {
358
+ setPaymentIntent(intent);
359
+ return intent;
360
+ })
361
+ .catch((err) => {
362
+ setError(err instanceof Error ? err.message : t('paymentError'));
363
+ return null;
364
+ })
365
+ .finally(() => setLoading(false));
366
+
367
+ // D) When both ready: resolve final SDK config and render
368
+ Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
369
+ if (!intent) return;
370
+
371
+ const sdk = resolveClientSdk(intent, providerSdk);
372
+ currentSdk = sdk;
373
+
374
+ // Sandbox mode — no SDK to load, UI handles it
375
+ if (sdk.renderType === 'sandbox') return;
376
+
377
+ if (sdk.renderType === 'redirect') {
378
+ if (!isAllowedPaymentUrl(intent.clientSecret)) {
379
+ setError(t('paymentRedirectBlocked'));
380
+ return;
381
+ }
382
+ safePaymentRedirect(intent.clientSecret);
383
+ return;
384
+ }
385
+
386
+ // Iframe mode: listen for postMessage from either:
387
+ // (1) the same-origin /payment-complete callback page after a provider
388
+ // redirect (legacy hosted-page flow), OR
389
+ // (2) a Brainerce-hosted embed page on an allowlisted payment host
390
+ // that wraps provider-specific logic (e.g. Cardcom OpenFields).
391
+ if (sdk.renderType === 'iframe') {
392
+ if (!isAllowedPaymentUrl(intent.clientSecret)) {
393
+ setError(t('paymentRedirectBlocked'));
394
+ return;
395
+ }
396
+ const iframeOrigin = (() => {
397
+ try {
398
+ return new URL(intent.clientSecret).origin;
399
+ } catch {
400
+ return '';
401
+ }
402
+ })();
403
+ const handleMessage = (event: MessageEvent) => {
404
+ const isSameOrigin = event.origin === window.location.origin;
405
+ const isTrustedIframe = iframeOrigin && event.origin === iframeOrigin;
406
+ if (!isSameOrigin && !isTrustedIframe) return;
407
+ if (event.data?.type === 'brainerce:resize') {
408
+ const h = Number((event.data as { height?: unknown }).height);
409
+ if (Number.isFinite(h) && h > 0 && h < 4000) setEmbeddedIframeHeight(h);
410
+ return;
411
+ }
412
+ // Embed page asking for a top-level redirect (e.g. Bit express-pay).
413
+ // We re-validate against the allowlist even though the URL comes
414
+ // from an already-trusted iframe — defense in depth.
415
+ if (event.data?.type === 'brainerce:redirect') {
416
+ const url = String((event.data as { url?: unknown }).url || '');
417
+ if (url) safePaymentRedirect(url);
418
+ return;
419
+ }
420
+ if (event.data?.type !== 'brainerce:payment-complete') return;
421
+
422
+ const params = event.data.data as Record<string, string> | undefined;
423
+ if (params?.failed === 'true') {
424
+ setError(t('paymentError'));
425
+ return;
426
+ }
427
+
428
+ // Map provider-specific params to normalized format for
429
+ // server-side verification (e.g. CardCom lowprofilecode → paymentIntentId)
430
+ const lowProfileCode = params?.lowprofilecode || params?.LowProfileCode;
431
+ const normalized: Record<string, unknown> = { ...params };
432
+ if (lowProfileCode) {
433
+ normalized.paymentIntentId = lowProfileCode;
434
+ }
435
+
436
+ // Trigger server-side verification + order creation
437
+ handleSuccess(normalized);
438
+ };
439
+ window.addEventListener('message', handleMessage);
440
+ cleanups.push(() => window.removeEventListener('message', handleMessage));
441
+ return;
442
+ }
443
+
444
+ if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
445
+
446
+ // Store for retryRender from onError callback
447
+ pendingRender = { sdk, intent };
448
+ cbRef.current.retryRender = retryRender;
449
+
450
+ // If SDK wasn't loaded from providers, load + init now
451
+ if (!sdkInitDone) {
452
+ loadScript(sdk);
453
+ // Wait for init to complete, then render once
454
+ const id = setInterval(() => {
455
+ if (sdkInitDone) {
456
+ clearInterval(id);
457
+ renderPayment(sdk, intent);
458
+ }
459
+ }, 100);
460
+ cleanups.push(() => clearInterval(id));
461
+ return;
462
+ }
463
+
464
+ // Re-init with final config if environment changed
465
+ if (sdk.initConfig?.environment && currentSdk) {
466
+ const global = (window as any)[sdk.globalName];
467
+ if (global) {
468
+ const method = sdk.initMethod || 'init';
469
+ global[method]({
470
+ ...(sdk.initConfig || {}),
471
+ events: {
472
+ onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
473
+ onFailure: (r: unknown) => cbRef.current.onFailure(r),
474
+ onError: (r: unknown) => cbRef.current.onError(r),
475
+ onTimeout: () => cbRef.current.onTimeout(),
476
+ onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
477
+ },
478
+ });
479
+ }
480
+ }
481
+
482
+ // SDK ready — render once
483
+ renderPayment(sdk, intent);
484
+ });
485
+
486
+ return () => cleanups.forEach((fn) => fn());
487
+ }, [checkoutId]);
488
+
489
+ // --- UI ---
490
+
491
+ if (loading) {
492
+ return (
493
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
494
+ <LoadingSpinner size="lg" />
495
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
496
+ </div>
497
+ );
498
+ }
499
+
500
+ if (error) {
501
+ const isNotConfigured =
502
+ error.toLowerCase().includes('not configured') ||
503
+ error.toLowerCase().includes('no payment') ||
504
+ error.toLowerCase().includes('provider');
505
+ return (
506
+ <div className={cn('py-12 text-center', className)}>
507
+ <svg
508
+ className="text-muted-foreground mx-auto mb-4 h-12 w-12"
509
+ fill="none"
510
+ viewBox="0 0 24 24"
511
+ stroke="currentColor"
512
+ >
513
+ <path
514
+ strokeLinecap="round"
515
+ strokeLinejoin="round"
516
+ strokeWidth={1.5}
517
+ 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"
518
+ />
519
+ </svg>
520
+ <h3 className="text-foreground mb-2 text-lg font-semibold">
521
+ {isNotConfigured ? t('paymentNotConfigured') : t('paymentError')}
522
+ </h3>
523
+ <p className="text-muted-foreground mx-auto max-w-md text-sm">
524
+ {isNotConfigured ? t('paymentNotConfiguredDesc') : error}
525
+ </p>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ if (!paymentIntent) return null;
531
+
532
+ const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
533
+
534
+ if (sdk.renderType === 'sandbox') {
535
+ const handleCompleteSandbox = async () => {
536
+ setLoading(true);
537
+ try {
538
+ const client = getClient();
539
+ await client.completeGuestCheckout(checkoutId);
540
+ window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
541
+ } catch (err) {
542
+ setError(err instanceof Error ? err.message : t('paymentError'));
543
+ setLoading(false);
544
+ }
545
+ };
546
+
547
+ return (
548
+ <div className={cn('py-8 text-center', className)}>
549
+ <div className="mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-6">
550
+ <svg
551
+ className="mx-auto mb-3 h-10 w-10 text-amber-500"
552
+ fill="none"
553
+ viewBox="0 0 24 24"
554
+ stroke="currentColor"
555
+ >
556
+ <path
557
+ strokeLinecap="round"
558
+ strokeLinejoin="round"
559
+ strokeWidth={1.5}
560
+ 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"
561
+ />
562
+ </svg>
563
+ <h3 className="text-foreground mb-1 text-lg font-semibold">{t('sandboxTitle')}</h3>
564
+ <p className="text-muted-foreground mb-4 text-sm">{t('sandboxDescription')}</p>
565
+ <button
566
+ onClick={handleCompleteSandbox}
567
+ 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"
568
+ >
569
+ {t('completeTestOrder')}
570
+ </button>
571
+ </div>
572
+ </div>
573
+ );
574
+ }
575
+
576
+ if (sdk.renderType === 'sdk-widget') {
577
+ const containerId =
578
+ sdk.containerId || `${paymentIntent.provider || 'payment'}-payment-container`;
579
+ return (
580
+ <div className={cn('py-4', className)}>
581
+ {!sdkReady && (
582
+ <div className="flex flex-col items-center justify-center py-8">
583
+ <LoadingSpinner size="lg" />
584
+ <p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
585
+ </div>
586
+ )}
587
+ <div id={containerId} />
588
+ </div>
589
+ );
590
+ }
591
+
592
+ if (sdk.renderType === 'iframe') {
593
+ if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
594
+
595
+ // Detect Brainerce-hosted embed (path-based — works across localhost/
596
+ // staging/prod without a domain list) vs. a provider-hosted page. The
597
+ // embed page is already brand-styled and compact → render inline in the
598
+ // checkout flow. Provider-hosted pages carry their own branding/chrome →
599
+ // keep the modal overlay so they don't fight the checkout layout.
600
+ const iframeUrlObj = (() => {
601
+ try {
602
+ return new URL(paymentIntent.clientSecret);
603
+ } catch {
604
+ return null;
605
+ }
606
+ })();
607
+ const isBrainerceEmbed = iframeUrlObj?.pathname.includes('/embed/') ?? false;
608
+
609
+ if (isBrainerceEmbed) {
610
+ // Inline: default to a reasonable height until the embed posts its real
611
+ // height via `brainerce:resize`. Transition smooths the resize into the
612
+ // final measurement.
613
+ const hasMeasured = embeddedIframeHeight !== null;
614
+ const iframeStyle: CSSProperties = {
615
+ height: hasMeasured ? (embeddedIframeHeight as number) : 540,
616
+ transition: hasMeasured ? 'height 0.2s ease-out' : undefined,
617
+ };
618
+ return (
619
+ <div className={cn('w-full', className)}>
620
+ <iframe
621
+ src={paymentIntent.clientSecret}
622
+ className="block w-full border-0"
623
+ style={iframeStyle}
624
+ title={t('payment')}
625
+ allow="payment"
626
+ />
627
+ </div>
628
+ );
629
+ }
630
+
631
+ // Provider-hosted page (e.g. Cardcom LowProfile with full merchant
632
+ // branding) — modal overlay keeps it visually contained.
633
+ const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
634
+ currency: paymentIntent.currency,
635
+ }) as string;
636
+ const iframeStyle: CSSProperties = { height: '90vh', minHeight: 700 };
637
+ return (
638
+ <>
639
+ {/* Modal overlay */}
640
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
641
+ <div className="bg-background relative mx-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl shadow-2xl">
642
+ {/* Header */}
643
+ <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-4">
644
+ <div className="flex min-w-0 flex-col">
645
+ <span className="text-foreground truncate text-sm font-semibold">
646
+ {storeInfo?.name}
647
+ </span>
648
+ <span className="text-muted-foreground text-xs">{t('payment')}</span>
649
+ </div>
650
+ <div className="flex items-baseline gap-1.5">
651
+ <span className="text-foreground text-lg font-bold tabular-nums">
652
+ {formattedAmount}
653
+ </span>
654
+ <span className="text-muted-foreground text-xs uppercase">
655
+ {paymentIntent.currency}
656
+ </span>
657
+ </div>
658
+ <button
659
+ onClick={() => {
660
+ window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
661
+ }}
662
+ 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"
663
+ aria-label="Close"
664
+ >
665
+ <svg
666
+ width="14"
667
+ height="14"
668
+ viewBox="0 0 14 14"
669
+ fill="none"
670
+ stroke="currentColor"
671
+ strokeWidth="2"
672
+ strokeLinecap="round"
673
+ >
674
+ <path d="M1 1l12 12M13 1L1 13" />
675
+ </svg>
676
+ </button>
677
+ </div>
678
+ {/* Iframe body */}
679
+ <iframe
680
+ src={paymentIntent.clientSecret}
681
+ className="w-full border-0"
682
+ style={iframeStyle}
683
+ title={t('payment')}
684
+ allow="payment"
685
+ />
686
+ {/* Footer */}
687
+ <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">
688
+ <svg
689
+ width="14"
690
+ height="14"
691
+ viewBox="0 0 24 24"
692
+ fill="none"
693
+ stroke="currentColor"
694
+ strokeWidth="2"
695
+ strokeLinecap="round"
696
+ strokeLinejoin="round"
697
+ aria-hidden="true"
698
+ >
699
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
700
+ <path d="m9 12 2 2 4-4" />
701
+ </svg>
702
+ <span>
703
+ {t('securePayment')} · <span className="font-medium">Brainerce</span>
704
+ </span>
705
+ </div>
706
+ </div>
707
+ </div>
708
+ {/* Placeholder so the checkout layout doesn't collapse */}
709
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
710
+ <LoadingSpinner size="lg" />
711
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
712
+ </div>
713
+ </>
714
+ );
715
+ }
716
+
717
+ return (
718
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
719
+ <LoadingSpinner size="lg" />
720
+ <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
721
+ <p className="text-muted-foreground mt-2 text-xs">
722
+ {t('redirectingHint')}
723
+ <a href={paymentIntent.clientSecret} className="text-primary hover:underline">
724
+ {t('clickHere')}
725
+ </a>
726
+ .
727
+ </p>
728
+ </div>
729
+ );
730
+ }