'use client'; import { useEffect, useState, useRef, useCallback, type CSSProperties } from 'react'; import type { PaymentIntent, PaymentClientSdk } from 'brainerce'; import { formatPrice } from 'brainerce'; import { BrainerceError } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useTranslations } from '@/core/lib/translations'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { useStoreInfo } from '@/core/providers/store-provider'; import { cn } from '@/core/lib/utils'; import { isAllowedPaymentUrl, isValidCheckoutId, safePaymentRedirect, } from '@/core/lib/safe-redirect'; import { PREFERRED_RENDER_MODE, resolveRenderType } from '@/core/lib/render-mode'; /** * Backward-compat defaults when backend doesn't return clientSdk. */ const LEGACY_GROW_SDK: PaymentClientSdk = { renderType: 'sdk-widget', scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js', globalName: 'growPayment', initMethod: 'init', renderMethod: 'renderPaymentOptions', containerId: 'grow-payment-container', initConfig: { version: 1, environment: 'DEV' }, additionalScripts: [ { url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true }, ], bodyStyles: '[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}', }; interface PaymentStepProps { checkoutId: string; className?: string; } function resolveClientSdk( intent: PaymentIntent | null, preloadedSdk?: PaymentClientSdk | null ): PaymentClientSdk { // Runtime SDK (returned by the payment app in the intent) wins over the // preloaded manifest SDK. This lets a provider return a different renderType // per-installation (e.g. Cardcom returning 'embedded-fields' when the // merchant opts in, while the manifest default stays 'iframe'). const fullSdk = [intent?.clientSdk, preloadedSdk].find((s) => s?.renderType); const runtimeSdk = intent?.clientSdk; if (fullSdk) { if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk; return { ...fullSdk, ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}), ...(runtimeSdk.initConfig ? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } } : {}), }; } const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null; if (legacy && runtimeSdk) { return { ...legacy, ...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}), ...(runtimeSdk.initConfig ? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } } : {}), }; } if (legacy) return legacy; return { renderType: 'redirect' }; } /** * Brainerce-hosted embed (Cardcom OpenFields) vs a provider-hosted page. * Path-based so it holds across localhost/staging/prod without a domain list. */ function isBrainerceEmbedUrl(url: string): boolean { try { return new URL(url).pathname.includes('/embed/'); } catch { return false; } } function extractMessage(response: unknown): string { if (typeof response === 'string') return response; return (response as { message?: string })?.message || ''; } export function PaymentStep({ checkoutId, className }: PaymentStepProps) { const t = useTranslations('checkout'); const { storeInfo } = useStoreInfo(); const [paymentIntent, setPaymentIntent] = useState(null); // The provider's payment URL. `clientSdk.renderArg` is the URL; `clientSecret` // is only a fallback for providers that duplicate it there. Reading // clientSecret alone breaks providers that return a real identifier (MAX, // Takbull) — the iframe/link silently points at an id instead of a page. // // Every allowlist check and embed-detection below must read THIS value, not // `paymentIntent.clientSecret` directly — that was the bug: two checks kept // reading clientSecret straight off the intent after this fallback was // introduced, so a MAX/Takbull-style provider failed `new URL()` on a bare // id and the payment step silently rendered nothing. const paymentIntentUrl = paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || ''; const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntentUrl); const [preloadedSdk, setPreloadedSdk] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [sdkReady, setSdkReady] = useState(false); // Set by the Cardcom OpenFields embed page via `brainerce:resize` postMessage. // Presence of this value is how we distinguish our own compact embed page // from a provider's hosted page — used to narrow the modal + auto-size the // iframe instead of reserving the tall LowProfile footprint. const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState(null); // Set when the inline embed never posts its first `brainerce:resize` within // the timeout below — e.g. the parent's CSP blocks the iframe from framing // at all. Without this the shopper stares at a permanently blank/broken box // with no error and no way forward. const [iframeLoadFailed, setIframeLoadFailed] = useState(false); // Provider-hosted iframe modal, closed by the shopper. Without this the // modal had no closed state at all: the X did a full navigation to // `?canceled=true`, the page resumed on the payment step, a fresh intent // re-opened the modal over the "payment canceled" banner, and browser Back // did the same — the shopper could never get back to the checkout. Starts // closed when the URL already says the previous attempt was canceled. const [modalDismissed, setModalDismissed] = useState( () => typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('canceled') === 'true' ); const walletOpenRef = useRef(false); const initialized = useRef(false); // Whether the provider-hosted modal is on screen right now. Derived, not // stored, so every branch below agrees on it. const providerModalOpen = !modalDismissed && !!paymentIntent && resolveClientSdk(paymentIntent, preloadedSdk).renderType === 'iframe' && !isBrainerceEmbed; // Back closes the modal instead of leaving the shopper inside it. Opening // pushes one history entry tagged as ours; popping it (Back, or the X below // calling history.back()) dismisses. A provider page that navigates inside // the iframe adds entries of its own on top, so Back may need a press per // in-frame navigation first — that is the browser's rule, not ours, and it // still ends on the checkout rather than looping through the payment page. useEffect(() => { if (!providerModalOpen) return; const marker = { brainercePaymentModal: true }; window.history.pushState(marker, ''); const onPop = () => setModalDismissed(true); window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, [providerModalOpen]); const closeProviderModal = useCallback(() => { if ( (window.history.state as { brainercePaymentModal?: boolean } | null)?.brainercePaymentModal ) { // Our entry is on top: popping it fires popstate, which dismisses and // leaves the history as it was before the modal opened. window.history.back(); } else { setModalDismissed(true); } }, []); // Stable refs for SDK event callbacks (avoids stale closures in onload) const cbRef = useRef({ onSuccess: (_r: unknown) => {}, onFailure: (_r: unknown) => {}, onError: (_r: unknown) => {}, onTimeout: () => {}, onWalletChange: (_s: string) => {}, retryRender: () => {}, }); const handleSuccess = useCallback( async (response: unknown) => { console.info('Payment SDK success:', JSON.stringify(response)); try { const client = getClient(); const resp = response as Record; const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as | Record | undefined; await client.confirmSdkPayment(checkoutId, data || undefined); } catch (err) { console.warn('Failed to confirm payment with backend:', err); } window.location.href = `/order-confirmation?checkout_id=${checkoutId}`; }, [checkoutId] ); cbRef.current = { onSuccess: handleSuccess, onFailure: (response: unknown) => { console.error('Payment SDK failure:', response); setError(extractMessage(response) || t('paymentError')); }, onError: (response: unknown) => { const TRANSIENT = [ 'Wallet not initialized', "SDK was not loaded as needed and therefore can't run", ]; const msg = extractMessage(response); if (TRANSIENT.some((e) => msg.includes(e))) { console.info('Payment SDK: transient error, retrying render in 1s:', msg); setTimeout(() => cbRef.current.retryRender(), 1000); return; } console.error('Payment SDK error:', response); setError(msg || t('paymentError')); }, onTimeout: () => { console.warn('Payment SDK: wallet timed out'); setError(t('paymentTimedOut')); }, onWalletChange: (state: string) => { console.info('Payment SDK wallet state:', state); if (state === 'open') { walletOpenRef.current = true; setSdkReady(true); } if (state === 'close') setSdkReady(false); }, retryRender: () => {}, }; // ========================================================================= // MAIN EFFECT — Follows Grow SDK docs exactly: // // Step 1: Load gs.min.js (insertBefore, as docs show) // Step 2: s.onload → growPayment.init({ environment, version, events }) // This triggers the SDK to load mp.min.js → CSS, HTML, params, services // Step 3: createPaymentIntent (starts wallet timer — should be AFTER init) // Step 4: growPayment.renderPaymentOptions(authCode) // // "call createPaymentProcess right before you need to render the wallet" // ========================================================================= useEffect(() => { // Defense in depth: the parent already validates checkoutId from URL // params, but we re-check here so the component is safe to render in any // context. Invalid id → no SDK loading, no API calls (error UI below). if (!isValidCheckoutId(checkoutId)) return; if (initialized.current) return; initialized.current = true; const client = getClient(); const iframeSuccessUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}`; const iframeFailedUrl = `${window.location.origin}/payment-complete?checkout_id=${checkoutId}&failed=true`; const redirectSuccessUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`; const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`; let sdkInitDone = false; let currentSdk: PaymentClientSdk | null = null; const cleanups: (() => void)[] = []; // --- Load SDK script exactly as Grow docs show --- function loadScript(sdk: PaymentClientSdk) { if (!sdk.scriptUrl || !sdk.globalName) return; // Inject bodyStyles if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) { const style = document.createElement('style'); style.setAttribute('data-payment-sdk', 'true'); style.textContent = sdk.bodyStyles; document.head.appendChild(style); cleanups.push(() => style.remove()); } // Additional scripts (Apple Pay etc.) — fire and forget if (sdk.additionalScripts) { for (const extra of sdk.additionalScripts) { if (document.querySelector(`script[src="${extra.url}"]`)) continue; const s = document.createElement('script'); s.type = 'text/javascript'; s.async = true; s.src = extra.url; const ref = document.getElementsByTagName('script')[0]; if (ref?.parentNode) ref.parentNode.insertBefore(s, ref); else document.head.appendChild(s); } } // Already loaded? Init immediately if ((window as any)[sdk.globalName]) { initSdk(sdk); return; } // Already loading (from a previous call)? Wait for it instead of duplicating if (document.querySelector(`script[src="${sdk.scriptUrl}"]`)) { const waitId = setInterval(() => { if ((window as any)[sdk.globalName!]) { clearInterval(waitId); initSdk(sdk); } }, 100); cleanups.push(() => clearInterval(waitId)); return; } // Load main SDK — insertBefore first