import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { LoaderCircle, CheckCircle2, XCircle, ShieldCheck, Receipt, Headphones } from 'lucide-react'; import { useCheckoutWizard } from '../WizardContext'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { previewCoupon as requestCouponPreview } from '@/lib/coupons'; import type { CouponPreviewResponse } from '@/types/coupon'; import { cn } from '@/lib/utils'; import { useRateLimitHelper } from '@/hooks/useRateLimitHelper'; import toast from 'react-hot-toast'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useAnalytics } from '@/hooks/useAnalytics'; type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; declare global { interface Window { Paddle?: { Environment?: { set: (environment: string) => void; }; Initialize?: (options: { token: string }) => void; Checkout: { open: (options: Record) => void; }; }; } } const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js'; const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no']; const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground'; const getCookieValue = (name: string): string | null => { if (typeof document === 'undefined') { return null; } const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; }; export function resolveCheckoutCsrfToken(): string { if (typeof document === 'undefined') { return ''; } const cookieToken = getCookieValue('XSRF-TOKEN'); if (cookieToken) { return cookieToken; } const metaToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); if (metaToken && metaToken.length > 0) { return metaToken; } return ''; } async function refreshCheckoutCsrfToken(): Promise { if (typeof window === 'undefined') { return; } try { await fetch('/sanctum/csrf-cookie', { method: 'GET', credentials: 'same-origin', }); } catch (error) { console.warn('[Checkout] Failed to refresh CSRF cookie', error); } } function buildCheckoutHeaders(): HeadersInit { const headers: HeadersInit = { 'Content-Type': 'application/json', Accept: 'application/json', }; const cookieToken = getCookieValue('XSRF-TOKEN'); if (cookieToken) { headers['X-XSRF-TOKEN'] = cookieToken; return headers; } const metaToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); if (metaToken && metaToken.length > 0) { headers['X-CSRF-TOKEN'] = metaToken; } return headers; } export function resolvePaddleLocale(rawLocale?: string | null): string { if (!rawLocale) { return 'en'; } const normalized = rawLocale.toLowerCase(); if (PADDLE_SUPPORTED_LOCALES.includes(normalized)) { return normalized; } const short = normalized.split('-')[0]; if (short && PADDLE_SUPPORTED_LOCALES.includes(short)) { return short; } return 'en'; } type PaddleEnvironment = 'sandbox' | 'production'; let paddleLoaderPromise: Promise | null = null; function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null { if (!paddle) { return null; } try { paddle.Environment?.set?.(environment); } catch (error) { console.warn('[Paddle] Failed to set environment', error); } return paddle; } async function loadPaddle(environment: PaddleEnvironment): Promise { if (typeof window === 'undefined') { return null; } if (window.Paddle) { return configurePaddle(window.Paddle, environment); } if (!paddleLoaderPromise) { paddleLoaderPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = PADDLE_SCRIPT_URL; script.async = true; script.onload = () => resolve(window.Paddle ?? null); script.onerror = (error) => reject(error); document.head.appendChild(script); }).catch((error) => { console.error('Failed to load Paddle.js', error); paddleLoaderPromise = null; return null; }); } const paddle = await paddleLoaderPromise; return configurePaddle(paddle, environment); } const PaddleCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => { const { t } = useTranslation('marketing'); return ( ); }; export const PaymentStep: React.FC = () => { const { t, i18n } = useTranslation('marketing'); const { trackEvent } = useAnalytics(); const { selectedPackage, nextStep, paddleConfig, authUser, isAuthenticated, goToStep, setPaymentCompleted, checkoutSessionId, setCheckoutSessionId, } = useCheckoutWizard(); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); const [initialised, setInitialised] = useState(false); const [inlineActive, setInlineActive] = useState(false); const [acceptedTerms, setAcceptedTerms] = useState(false); const [consentError, setConsentError] = useState(null); const [couponCode, setCouponCode] = useState(() => { if (typeof window === 'undefined') { return ''; } const params = new URLSearchParams(window.location.search); const fromQuery = params.get('coupon'); if (fromQuery) { return fromQuery; } return localStorage.getItem('preferred_coupon_code') ?? ''; }); const [couponPreview, setCouponPreview] = useState(null); const [couponError, setCouponError] = useState(null); const [couponNotice, setCouponNotice] = useState(null); const [couponLoading, setCouponLoading] = useState(false); const paddleRef = useRef(null); const checkoutContainerRef = useRef(null); const eventCallbackRef = useRef<(event: Record) => void>(); const hasAutoAppliedCoupon = useRef(false); const checkoutContainerClass = 'paddle-checkout-container'; const [showWithdrawalModal, setShowWithdrawalModal] = useState(false); const [withdrawalHtml, setWithdrawalHtml] = useState(null); const [withdrawalTitle, setWithdrawalTitle] = useState(null); const [withdrawalLoading, setWithdrawalLoading] = useState(false); const [withdrawalError, setWithdrawalError] = useState(null); const RateLimitHelper = useRateLimitHelper('coupon'); const [voucherExpiry, setVoucherExpiry] = useState(null); const [isGiftVoucher, setIsGiftVoucher] = useState(false); const [freeActivationBusy, setFreeActivationBusy] = useState(false); const [pendingConfirmation, setPendingConfirmation] = useState<{ transactionId: string | null; checkoutId: string | null; } | null>(null); const paddleLocale = useMemo(() => { const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); return resolvePaddleLocale(sourceLocale ?? undefined); }, [i18n.language]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); const confirmCheckoutSession = useCallback(async (payload: { transactionId: string | null; checkoutId: string | null }) => { if (!checkoutSessionId) { return; } if (!payload.transactionId && !payload.checkoutId) { return; } try { await refreshCheckoutCsrfToken(); await fetch(`/checkout/session/${checkoutSessionId}/confirm`, { method: 'POST', headers: buildCheckoutHeaders(), credentials: 'same-origin', body: JSON.stringify({ transaction_id: payload.transactionId, checkout_id: payload.checkoutId, }), }); } catch (error) { console.warn('Failed to confirm Paddle session', error); } }, [checkoutSessionId]); const applyCoupon = useCallback(async (code: string) => { if (!selectedPackage) { return; } const trimmed = code.trim(); if (!trimmed) { setCouponError(t('coupon.errors.required')); setCouponPreview(null); setCouponNotice(null); return; } if (RateLimitHelper.isLimited(trimmed)) { setCouponError(t('coupon.errors.too_many_attempts')); trackEvent({ category: 'marketing_coupon', action: 'rate_limited', }); return; } setCouponLoading(true); setCouponError(null); setCouponNotice(null); try { const preview = await requestCouponPreview(selectedPackage.id, trimmed); setCouponPreview(preview); setCouponNotice( t('coupon.applied', { code: preview.coupon.code, amount: preview.pricing.formatted.discount, }) ); trackEvent({ category: 'marketing_coupon', action: 'applied', name: preview.coupon.code, }); setVoucherExpiry(preview.coupon.expires_at ?? null); setIsGiftVoucher(preview.coupon.code?.toUpperCase().startsWith('GIFT-') ?? false); if (typeof window !== 'undefined') { localStorage.setItem('preferred_coupon_code', preview.coupon.code); } } catch (error) { setCouponPreview(null); setCouponNotice(null); setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic')); trackEvent({ category: 'marketing_coupon', action: 'apply_failed', }); RateLimitHelper.bump(trimmed); } finally { setCouponLoading(false); } }, [RateLimitHelper, selectedPackage, t, trackEvent]); useEffect(() => { if (hasAutoAppliedCoupon.current) { return; } if (couponCode && selectedPackage) { hasAutoAppliedCoupon.current = true; applyCoupon(couponCode); } }, [applyCoupon, couponCode, selectedPackage]); useEffect(() => { setCouponPreview(null); setCouponNotice(null); setCouponError(null); hasAutoAppliedCoupon.current = false; }, [selectedPackage?.id]); useEffect(() => { if (typeof window === 'undefined') { return; } const params = new URLSearchParams(window.location.search); const queryCoupon = params.get('coupon'); if (queryCoupon) { const normalized = queryCoupon.toUpperCase(); setCouponCode((current) => current || normalized); localStorage.setItem('preferred_coupon_code', normalized); } }, []); useEffect(() => { if (typeof window !== 'undefined' && couponCode) { localStorage.setItem('preferred_coupon_code', couponCode); } }, [couponCode]); const handleFreeActivation = async () => { if (!isAuthenticated || !authUser) { const message = t('checkout.payment_step.auth_required'); toast.error(message); goToStep('auth'); return; } if (!selectedPackage) { return; } if (!acceptedTerms) { setConsentError(t('checkout.legal.checkbox_terms_error')); return; } setConsentError(null); setFreeActivationBusy(true); try { await refreshCheckoutCsrfToken(); const response = await fetch('/checkout/free-activate', { method: 'POST', headers: buildCheckoutHeaders(), credentials: 'same-origin', body: JSON.stringify({ package_id: selectedPackage.id, accepted_terms: acceptedTerms, locale: paddleLocale, }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_error'); setConsentError(errorMessage); toast.error(errorMessage); return; } setCheckoutSessionId(payload?.checkout_session_id ?? null); setPaymentCompleted(true); nextStep(); } catch (error) { console.error('Failed to activate free package', error); const fallbackMessage = t('checkout.payment_step.paddle_error'); setConsentError(fallbackMessage); toast.error(fallbackMessage); } finally { setFreeActivationBusy(false); } }; const startPaddleCheckout = async () => { if (!selectedPackage) { return; } if (!isAuthenticated || !authUser) { const message = t('checkout.payment_step.auth_required'); setStatus('error'); setMessage(message); toast.error(message); goToStep('auth'); return; } if (!acceptedTerms) { setConsentError(t('checkout.legal.checkbox_terms_error')); return; } if (!selectedPackage.paddle_price_id) { setStatus('error'); setMessage(t('checkout.payment_step.paddle_not_configured')); return; } setPaymentCompleted(false); setStatus('processing'); setMessage(t('checkout.payment_step.paddle_preparing')); setInlineActive(false); setCheckoutSessionId(null); try { await refreshCheckoutCsrfToken(); const inlineSupported = initialised && !!paddleConfig?.client_token; if (typeof window !== 'undefined') { console.info('[Checkout] Paddle inline status', { inlineSupported, initialised, hasClientToken: Boolean(paddleConfig?.client_token), environment: paddleConfig?.environment, paddlePriceId: selectedPackage.paddle_price_id, }); } const response = await fetch('/paddle/create-checkout', { method: 'POST', headers: buildCheckoutHeaders(), credentials: 'same-origin', body: JSON.stringify({ package_id: selectedPackage.id, locale: paddleLocale, coupon_code: couponPreview?.coupon.code ?? undefined, accepted_terms: acceptedTerms, inline: inlineSupported, }), }); const rawBody = await response.text(); if ( response.status === 401 || response.status === 419 || (response.redirected && response.url.includes('/login')) ) { const message = t('checkout.payment_step.auth_required'); setStatus('error'); setMessage(message); toast.error(message); goToStep('auth'); return; } if (typeof window !== 'undefined') { console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody }); } let data: { checkout_url?: string; message?: string } | null = null; try { data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; } catch (parseError) { console.warn('Failed to parse Paddle checkout payload as JSON', parseError); data = null; } if (data && typeof (data as { checkout_session_id?: string }).checkout_session_id === 'string') { setCheckoutSessionId((data as { checkout_session_id?: string }).checkout_session_id ?? null); } let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null; if (!checkoutUrl) { const trimmed = rawBody.trim(); if (/^https?:\/\//i.test(trimmed)) { checkoutUrl = trimmed; } else if (trimmed.startsWith('<')) { const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/); if (match) { checkoutUrl = match[0]; } } } if (!response.ok || !checkoutUrl) { const message = data?.message || rawBody || 'Unable to create Paddle checkout.'; if (response.ok && data && (data as { mode?: string }).mode === 'inline') { checkoutUrl = null; } else { throw new Error(message); } } if (data && (data as { mode?: string }).mode === 'inline') { const paddle = paddleRef.current; if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') { throw new Error('Inline Paddle checkout is not available.'); } const inlinePayload: Record = { items: (data as { items?: unknown[] }).items ?? [], settings: { displayMode: 'inline', frameTarget: checkoutContainerClass, frameInitialHeight: '550', frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', theme: 'light', locale: paddleLocale, }, }; if ((data as { custom_data?: Record }).custom_data) { inlinePayload.customData = (data as { custom_data?: Record }).custom_data; } if ((data as { customer?: Record }).customer) { inlinePayload.customer = (data as { customer?: Record }).customer; } if (typeof window !== 'undefined') { console.info('[Checkout] Opening inline Paddle checkout', inlinePayload); } paddle.Checkout.open(inlinePayload); setInlineActive(true); setStatus('ready'); setMessage(t('checkout.payment_step.paddle_overlay_ready')); if (typeof window !== 'undefined' && checkoutContainerRef.current) { window.requestAnimationFrame(() => { const rect = checkoutContainerRef.current?.getBoundingClientRect(); if (!rect) { return; } const offset = 120; const target = Math.max(window.scrollY + rect.top - offset, 0); window.scrollTo({ top: target, behavior: 'smooth' }); }); } return; } window.open(checkoutUrl, '_blank', 'noopener'); setInlineActive(false); setStatus('ready'); setMessage(t('checkout.payment_step.paddle_ready')); } catch (error) { console.error('Failed to start Paddle checkout', error); setStatus('error'); setMessage(t('checkout.payment_step.paddle_error')); setInlineActive(false); setPaymentCompleted(false); } }; useEffect(() => { let cancelled = false; const environment = paddleConfig?.environment === 'sandbox' ? 'sandbox' : 'production'; const clientToken = paddleConfig?.client_token ?? null; eventCallbackRef.current = (event) => { if (!event?.name) { return; } if (typeof window !== 'undefined') { console.debug('[Checkout] Paddle event', event); } if (event.name === 'checkout.completed') { const transactionId = typeof event?.data?.transaction_id === 'string' ? event.data.transaction_id : null; const checkoutId = typeof event?.data?.id === 'string' ? event.data.id : null; setStatus('processing'); setMessage(t('checkout.payment_step.processing_confirmation')); setInlineActive(false); setPaymentCompleted(false); setPendingConfirmation({ transactionId, checkoutId }); toast.success(t('checkout.payment_step.toast_success')); setPaymentCompleted(true); nextStep(); } if (event.name === 'checkout.closed') { setStatus('idle'); setMessage(''); setInlineActive(false); } if (event.name === 'checkout.error') { setStatus('error'); setMessage(t('checkout.payment_step.paddle_error')); setInlineActive(false); setPaymentCompleted(false); } }; (async () => { const paddle = await loadPaddle(environment); if (cancelled || !paddle) { return; } try { let inlineReady = false; if (typeof paddle.Initialize === 'function' && clientToken) { if (typeof window !== 'undefined') { console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) }); } paddle.Initialize({ token: clientToken, checkout: { settings: { displayMode: 'inline', frameTarget: checkoutContainerClass, frameInitialHeight: '550', frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', locale: paddleLocale, }, }, eventCallback: (event: Record) => eventCallbackRef.current?.(event), }); inlineReady = true; } paddleRef.current = paddle; setInitialised(inlineReady); } catch (error) { console.error('Failed to initialize Paddle', error); setInitialised(false); setStatus('error'); setMessage(t('checkout.payment_step.paddle_error')); setPaymentCompleted(false); } })(); return () => { cancelled = true; }; }, [nextStep, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]); useEffect(() => { setPaymentCompleted(false); setCheckoutSessionId(null); setStatus('idle'); setMessage(''); setInlineActive(false); setPendingConfirmation(null); }, [selectedPackage?.id, setPaymentCompleted]); useEffect(() => { if (!pendingConfirmation || !checkoutSessionId) { return; } void confirmCheckoutSession(pendingConfirmation); setPendingConfirmation(null); }, [checkoutSessionId, confirmCheckoutSession, pendingConfirmation]); const handleCouponSubmit = useCallback((event: FormEvent) => { event.preventDefault(); if (!selectedPackage) { return; } applyCoupon(couponCode); }, [applyCoupon, couponCode, selectedPackage]); const handleRemoveCoupon = useCallback(() => { if (couponPreview?.coupon.code) { trackEvent({ category: 'marketing_coupon', action: 'removed', name: couponPreview.coupon.code, }); } setCouponPreview(null); setCouponNotice(null); setCouponError(null); setCouponCode(''); if (typeof window !== 'undefined') { localStorage.removeItem('preferred_coupon_code'); } }, [couponPreview, trackEvent]); const openWithdrawalModal = useCallback(async () => { setShowWithdrawalModal(true); if (withdrawalHtml || withdrawalLoading) { return; } setWithdrawalLoading(true); setWithdrawalError(null); try { const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${paddleLocale}`); if (!response.ok) { throw new Error(`Failed to load withdrawal page (${response.status})`); } const data = await response.json(); setWithdrawalHtml(data.body_html || ''); setWithdrawalTitle(data.title || t('checkout.legal.link_cancellation')); } catch (error) { setWithdrawalError(t('checkout.legal.modal_error')); } finally { setWithdrawalLoading(false); } }, [paddleLocale, t, withdrawalHtml, withdrawalLoading]); if (!selectedPackage) { return ( {t('checkout.payment_step.no_package_title')} {t('checkout.payment_step.no_package_description')} ); } if (isFree) { return (
{t('checkout.payment_step.free_package_title')} {t('checkout.payment_step.free_package_desc')}
{ setAcceptedTerms(Boolean(checked)); if (consentError) { setConsentError(null); } }} />

{t('checkout.legal.legal_links_intro')}{' '}

{consentError && (
{consentError}
)}
); } const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
{label}
); const PaddleLogo = () => (
Paddle {t('checkout.payment_step.paddle_partner')}
); return (
{!inlineActive && (

{t('checkout.payment_step.guided_title')}

{t('checkout.payment_step.guided_body')}

{ setAcceptedTerms(Boolean(checked)); if (consentError) { setConsentError(null); } }} className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]" />

{t('checkout.legal.legal_links_intro')}{' '}

{consentError && (
{consentError}
)}

{t('checkout.payment_step.guided_cta_hint')}

)}
setCouponCode(event.target.value.toUpperCase())} placeholder={t('coupon.placeholder')} className="flex-1" />
{couponPreview && ( )}
{couponError && (
{couponError}
)} {couponNotice && (
{couponNotice}
)} {couponPreview && (

{t('coupon.summary_title')}

{t('coupon.fields.subtotal')} {couponPreview.pricing.formatted.subtotal}
{t('coupon.fields.discount')} {couponPreview.pricing.formatted.discount}
{t('coupon.fields.tax')} {couponPreview.pricing.formatted.tax}
{t('coupon.fields.total')} {couponPreview.pricing.formatted.total}
{voucherExpiry && (
{t('coupon.fields.expires')} {new Date(voucherExpiry).toLocaleDateString(i18n.language)}
)}
)} {isGiftVoucher && (
{t('coupon.legal_note')}{' '} {t('coupon.legal_link')}
)}
{!inlineActive && (

{t('checkout.payment_step.paddle_intro')}

)} {status !== 'idle' && ( {status === 'processing' ? t('checkout.payment_step.status_processing_title') : status === 'ready' ? t('checkout.payment_step.status_ready_title') : status === 'error' ? t('checkout.payment_step.status_error_title') : t('checkout.payment_step.status_info_title')} {message} {status === 'processing' && } )}

{t('checkout.payment_step.paddle_disclaimer')}

{withdrawalTitle || t('checkout.legal.link_cancellation')} {t('checkout.legal.modal_description')}
{withdrawalLoading && (
{t('checkout.legal.modal_loading')}
)} {withdrawalError && (
{withdrawalError}
)} {!withdrawalLoading && !withdrawalError && withdrawalHtml && (
)}
); };