checkout_id direkt an das Backend, damit der Server die Session via Paddle‑API finalisiert (auch wenn der Webhook nicht greift). Dadurch sollte “Zahlung wird verarbeitet” nicht mehr hängen bleiben.
1152 lines
40 KiB
TypeScript
1152 lines
40 KiB
TypeScript
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';
|
|
|
|
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<string, unknown>) => 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 metaToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
|
|
if (metaToken && metaToken.length > 0) {
|
|
return metaToken;
|
|
}
|
|
|
|
return getCookieValue('XSRF-TOKEN') ?? '';
|
|
}
|
|
|
|
function buildCheckoutHeaders(): HeadersInit {
|
|
const csrfToken = resolveCheckoutCsrfToken();
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
};
|
|
|
|
if (csrfToken) {
|
|
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
headers['X-XSRF-TOKEN'] = csrfToken;
|
|
}
|
|
|
|
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<typeof window.Paddle | null> | 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<typeof window.Paddle | null> {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
if (window.Paddle) {
|
|
return configurePaddle(window.Paddle, environment);
|
|
}
|
|
|
|
if (!paddleLoaderPromise) {
|
|
paddleLoaderPromise = new Promise<typeof window.Paddle | null>((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<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
|
|
const { t } = useTranslation('marketing');
|
|
|
|
return (
|
|
<Button
|
|
size="lg"
|
|
className={cn('w-full sm:w-auto', className)}
|
|
disabled={disabled}
|
|
onClick={onCheckout}
|
|
>
|
|
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('checkout.payment_step.pay_with_paddle')}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
export const PaymentStep: React.FC = () => {
|
|
const { t, i18n } = useTranslation('marketing');
|
|
const { selectedPackage, nextStep, paddleConfig, authUser, paymentCompleted, setPaymentCompleted } = useCheckoutWizard();
|
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
|
const [message, setMessage] = useState<string>('');
|
|
const [initialised, setInitialised] = useState(false);
|
|
const [inlineActive, setInlineActive] = useState(false);
|
|
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
const [consentError, setConsentError] = useState<string | null>(null);
|
|
const [couponCode, setCouponCode] = useState<string>(() => {
|
|
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<CouponPreviewResponse | null>(null);
|
|
const [couponError, setCouponError] = useState<string | null>(null);
|
|
const [couponNotice, setCouponNotice] = useState<string | null>(null);
|
|
const [couponLoading, setCouponLoading] = useState(false);
|
|
const paddleRef = useRef<typeof window.Paddle | null>(null);
|
|
const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>();
|
|
const hasAutoAppliedCoupon = useRef(false);
|
|
const checkoutContainerClass = 'paddle-checkout-container';
|
|
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
|
|
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
|
|
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
|
|
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
|
|
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
|
|
const RateLimitHelper = useRateLimitHelper('coupon');
|
|
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
|
|
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
|
|
const [checkoutSessionId, setCheckoutSessionId] = useState<string | null>(null);
|
|
const [freeActivationBusy, setFreeActivationBusy] = useState(false);
|
|
const [awaitingConfirmation, setAwaitingConfirmation] = useState(false);
|
|
const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0);
|
|
const confirmationTimerRef = useRef<number | null>(null);
|
|
const statusCheckRef = useRef<(() => void) | null>(null);
|
|
const confirmRequestRef = useRef(false);
|
|
|
|
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: Record<string, unknown>) => {
|
|
if (!checkoutSessionId) {
|
|
return;
|
|
}
|
|
|
|
const transactionId = typeof payload?.transaction_id === 'string' ? payload.transaction_id : null;
|
|
const checkoutId = typeof payload?.id === 'string' ? payload.id : null;
|
|
|
|
if (!transactionId && !checkoutId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fetch(`/checkout/session/${checkoutSessionId}/confirm`, {
|
|
method: 'POST',
|
|
headers: buildCheckoutHeaders(),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
transaction_id: transactionId,
|
|
checkout_id: 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'));
|
|
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,
|
|
})
|
|
);
|
|
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'));
|
|
RateLimitHelper.bump(trimmed);
|
|
} finally {
|
|
setCouponLoading(false);
|
|
}
|
|
}, [selectedPackage, t]);
|
|
|
|
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 (!selectedPackage) {
|
|
return;
|
|
}
|
|
|
|
if (!acceptedTerms) {
|
|
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
|
return;
|
|
}
|
|
|
|
setConsentError(null);
|
|
setFreeActivationBusy(true);
|
|
setAwaitingConfirmation(false);
|
|
|
|
try {
|
|
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 (!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);
|
|
setAwaitingConfirmation(false);
|
|
setConfirmationElapsedMs(0);
|
|
|
|
try {
|
|
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 (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<string, unknown> = {
|
|
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<string, unknown> }).custom_data) {
|
|
inlinePayload.customData = (data as { custom_data?: Record<string, unknown> }).custom_data;
|
|
}
|
|
|
|
if ((data as { customer?: Record<string, unknown> }).customer) {
|
|
inlinePayload.customer = (data as { customer?: Record<string, unknown> }).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') {
|
|
setStatus('processing');
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
setInlineActive(false);
|
|
setAwaitingConfirmation(true);
|
|
setPaymentCompleted(false);
|
|
if (!confirmRequestRef.current) {
|
|
confirmRequestRef.current = true;
|
|
void confirmCheckoutSession(event.data as Record<string, unknown>);
|
|
}
|
|
toast.success(t('checkout.payment_step.toast_success'));
|
|
}
|
|
|
|
if (event.name === 'checkout.closed' && !awaitingConfirmation) {
|
|
setStatus('idle');
|
|
setMessage('');
|
|
setInlineActive(false);
|
|
setPaymentCompleted(false);
|
|
}
|
|
|
|
if (event.name === 'checkout.error') {
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
setInlineActive(false);
|
|
setAwaitingConfirmation(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<string, unknown>) => 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;
|
|
};
|
|
}, [awaitingConfirmation, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
|
|
|
useEffect(() => {
|
|
setPaymentCompleted(false);
|
|
setCheckoutSessionId(null);
|
|
setStatus('idle');
|
|
setMessage('');
|
|
setInlineActive(false);
|
|
setAwaitingConfirmation(false);
|
|
setConfirmationElapsedMs(0);
|
|
confirmRequestRef.current = false;
|
|
}, [selectedPackage?.id, setPaymentCompleted]);
|
|
|
|
useEffect(() => {
|
|
if (!awaitingConfirmation || typeof window === 'undefined') {
|
|
if (confirmationTimerRef.current) {
|
|
window.clearInterval(confirmationTimerRef.current);
|
|
confirmationTimerRef.current = null;
|
|
}
|
|
setConfirmationElapsedMs(0);
|
|
return;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
confirmationTimerRef.current = window.setInterval(() => {
|
|
setConfirmationElapsedMs(Date.now() - startedAt);
|
|
}, 1000);
|
|
|
|
return () => {
|
|
if (confirmationTimerRef.current) {
|
|
window.clearInterval(confirmationTimerRef.current);
|
|
confirmationTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [awaitingConfirmation]);
|
|
|
|
const checkSessionStatus = useCallback(async (): Promise<boolean> => {
|
|
if (!checkoutSessionId) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/checkout/session/${checkoutSessionId}/status`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return false;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
|
|
if (payload?.status === 'completed') {
|
|
setStatus('ready');
|
|
setMessage(t('checkout.payment_step.status_success'));
|
|
setInlineActive(false);
|
|
setAwaitingConfirmation(false);
|
|
setPaymentCompleted(true);
|
|
toast.success(t('checkout.payment_step.toast_success'));
|
|
nextStep();
|
|
return true;
|
|
}
|
|
|
|
if (payload?.status === 'failed' || payload?.status === 'cancelled') {
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
setAwaitingConfirmation(false);
|
|
setPaymentCompleted(false);
|
|
}
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}, [checkoutSessionId, nextStep, setPaymentCompleted, t]);
|
|
|
|
useEffect(() => {
|
|
statusCheckRef.current = () => {
|
|
void checkSessionStatus();
|
|
};
|
|
}, [checkSessionStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!checkoutSessionId || paymentCompleted) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let timeoutId: number | null = null;
|
|
|
|
const schedulePoll = () => {
|
|
if (cancelled || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
timeoutId = window.setTimeout(() => {
|
|
void pollStatus();
|
|
}, 5000);
|
|
};
|
|
|
|
const pollStatus = async () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const completed = await checkSessionStatus();
|
|
|
|
if (!completed) {
|
|
schedulePoll();
|
|
}
|
|
};
|
|
|
|
void pollStatus();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timeoutId && typeof window !== 'undefined') {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [checkSessionStatus, checkoutSessionId, paymentCompleted]);
|
|
|
|
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
if (!selectedPackage) {
|
|
return;
|
|
}
|
|
|
|
applyCoupon(couponCode);
|
|
}, [applyCoupon, couponCode, selectedPackage]);
|
|
|
|
const handleRemoveCoupon = useCallback(() => {
|
|
setCouponPreview(null);
|
|
setCouponNotice(null);
|
|
setCouponError(null);
|
|
setCouponCode('');
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem('preferred_coupon_code');
|
|
}
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
const showManualActions = awaitingConfirmation && confirmationElapsedMs >= 30000;
|
|
|
|
const handleStatusRetry = useCallback(() => {
|
|
setStatus('processing');
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
statusCheckRef.current?.();
|
|
}, [t]);
|
|
|
|
const handlePageRefresh = useCallback(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.reload();
|
|
}
|
|
}, []);
|
|
|
|
if (!selectedPackage) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('checkout.payment_step.no_package_title')}</AlertTitle>
|
|
<AlertDescription>{t('checkout.payment_step.no_package_description')}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (isFree) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Alert>
|
|
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
|
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
|
</Alert>
|
|
<div className="rounded-xl border bg-card p-4">
|
|
<div className="space-y-3">
|
|
<div className="flex items-start gap-3">
|
|
<Checkbox
|
|
id="checkout-terms-free"
|
|
checked={acceptedTerms}
|
|
onCheckedChange={(checked) => {
|
|
setAcceptedTerms(Boolean(checked));
|
|
if (consentError) {
|
|
setConsentError(null);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="space-y-1 text-sm">
|
|
<Label htmlFor="checkout-terms-free" className="cursor-pointer">
|
|
{t('checkout.legal.checkbox_terms_label')}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('checkout.legal.legal_links_intro')}{' '}
|
|
<button
|
|
type="button"
|
|
className="underline underline-offset-2"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openWithdrawalModal();
|
|
}}
|
|
>
|
|
{t('checkout.legal.open_withdrawal')}
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{consentError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
<XCircle className="h-4 w-4" />
|
|
<span>{consentError}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="lg"
|
|
onClick={handleFreeActivation}
|
|
disabled={freeActivationBusy || !acceptedTerms}
|
|
>
|
|
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('checkout.payment_step.activate_package')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (awaitingConfirmation) {
|
|
return (
|
|
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
<LoaderCircle className="h-6 w-6 animate-spin text-primary" />
|
|
<div className="space-y-1">
|
|
<h3 className="text-lg font-semibold">{t('checkout.payment_step.processing_title')}</h3>
|
|
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_body')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{showManualActions && (
|
|
<div className="mt-6 space-y-3">
|
|
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_manual_hint')}</p>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<Button type="button" variant="outline" onClick={handleStatusRetry}>
|
|
{t('checkout.payment_step.processing_retry')}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={handlePageRefresh}>
|
|
{t('checkout.payment_step.processing_refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
|
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
|
<Icon className="h-4 w-4 text-white/80" />
|
|
<span>{label}</span>
|
|
</div>
|
|
);
|
|
|
|
const PaddleLogo = () => (
|
|
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
|
|
<img
|
|
src="/paddle.logo.svg"
|
|
alt="Paddle"
|
|
className="h-6 w-auto brightness-0 invert"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
<span className="text-xs font-semibold">{t('checkout.payment_step.paddle_partner')}</span>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
|
<div className="space-y-6">
|
|
{!inlineActive && (
|
|
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
|
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="space-y-4">
|
|
<PaddleLogo />
|
|
<div className="space-y-2">
|
|
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
|
|
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<TrustPill icon={ShieldCheck} label={t('checkout.payment_step.trust_secure')} />
|
|
<TrustPill icon={Receipt} label={t('checkout.payment_step.trust_tax')} />
|
|
<TrustPill icon={Headphones} label={t('checkout.payment_step.trust_support')} />
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
|
|
<div className="space-y-3 rounded-xl border border-white/30 bg-white/10 p-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-start gap-3">
|
|
<Checkbox
|
|
id="checkout-terms-hero"
|
|
checked={acceptedTerms}
|
|
onCheckedChange={(checked) => {
|
|
setAcceptedTerms(Boolean(checked));
|
|
if (consentError) {
|
|
setConsentError(null);
|
|
}
|
|
}}
|
|
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
|
|
/>
|
|
<div className="space-y-1 text-sm">
|
|
<Label htmlFor="checkout-terms-hero" className="cursor-pointer text-white">
|
|
{t('checkout.legal.checkbox_terms_label')}
|
|
</Label>
|
|
<p className="text-xs text-white/80">
|
|
{t('checkout.legal.legal_links_intro')}{' '}
|
|
<button
|
|
type="button"
|
|
className="underline underline-offset-2"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openWithdrawalModal();
|
|
}}
|
|
>
|
|
{t('checkout.legal.open_withdrawal')}
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{consentError && (
|
|
<div className="flex items-center gap-2 text-sm text-red-200">
|
|
<XCircle className="h-4 w-4" />
|
|
<span>{consentError}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<PaddleCta
|
|
onCheckout={startPaddleCheckout}
|
|
disabled={status === 'processing' || !acceptedTerms}
|
|
isProcessing={status === 'processing'}
|
|
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
|
/>
|
|
<p className="text-xs text-white/70 text-center">
|
|
{t('checkout.payment_step.guided_cta_hint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
|
|
<Input
|
|
value={couponCode}
|
|
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
|
|
placeholder={t('coupon.placeholder')}
|
|
className="flex-1"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button type="submit" disabled={couponLoading || !couponCode.trim()}>
|
|
{couponLoading ? t('checkout.payment_step.status_processing_title') : t('coupon.apply')}
|
|
</Button>
|
|
{couponPreview && (
|
|
<Button type="button" variant="outline" onClick={handleRemoveCoupon}>
|
|
{t('coupon.remove')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
{couponError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
<XCircle className="h-4 w-4" />
|
|
<span>{couponError}</span>
|
|
</div>
|
|
)}
|
|
{couponNotice && (
|
|
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
<span>{couponNotice}</span>
|
|
</div>
|
|
)}
|
|
{couponPreview && (
|
|
<div className="rounded-lg border bg-muted/20 p-4 text-sm">
|
|
<p className="mb-3 font-medium text-muted-foreground">{t('coupon.summary_title')}</p>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span>{t('coupon.fields.subtotal')}</span>
|
|
<span>{couponPreview.pricing.formatted.subtotal}</span>
|
|
</div>
|
|
<div className="flex justify-between text-emerald-600">
|
|
<span>{t('coupon.fields.discount')}</span>
|
|
<span>{couponPreview.pricing.formatted.discount}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>{t('coupon.fields.tax')}</span>
|
|
<span>{couponPreview.pricing.formatted.tax}</span>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex justify-between font-semibold">
|
|
<span>{t('coupon.fields.total')}</span>
|
|
<span>{couponPreview.pricing.formatted.total}</span>
|
|
</div>
|
|
{voucherExpiry && (
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>{t('coupon.fields.expires')}</span>
|
|
<span>{new Date(voucherExpiry).toLocaleDateString(i18n.language)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isGiftVoucher && (
|
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground">
|
|
<span>{t('coupon.legal_note')}{' '}</span>
|
|
<a
|
|
href={i18n.language === 'de' ? '/de/widerrufsbelehrung' : '/en/withdrawal'}
|
|
className="text-primary underline"
|
|
>
|
|
{t('coupon.legal_link')}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!inlineActive && (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('checkout.payment_step.paddle_intro')}
|
|
</p>
|
|
<PaddleCta
|
|
onCheckout={startPaddleCheckout}
|
|
disabled={status === 'processing' || !acceptedTerms}
|
|
isProcessing={status === 'processing'}
|
|
className={PRIMARY_CTA_STYLES}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{status !== 'idle' && (
|
|
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
|
<AlertTitle>
|
|
{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')}
|
|
</AlertTitle>
|
|
<AlertDescription className="flex items-center gap-3">
|
|
<span>{message}</span>
|
|
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div ref={checkoutContainerRef} className={`${checkoutContainerClass} min-h-[360px]`} />
|
|
|
|
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
|
|
{t('checkout.payment_step.paddle_disclaimer')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={showWithdrawalModal} onOpenChange={setShowWithdrawalModal}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{withdrawalTitle || t('checkout.legal.link_cancellation')}</DialogTitle>
|
|
<DialogDescription>{t('checkout.legal.modal_description')}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="min-h-[200px]">
|
|
{withdrawalLoading && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
<span>{t('checkout.legal.modal_loading')}</span>
|
|
</div>
|
|
)}
|
|
{withdrawalError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
<XCircle className="h-4 w-4" />
|
|
<span>{withdrawalError}</span>
|
|
</div>
|
|
)}
|
|
{!withdrawalLoading && !withdrawalError && withdrawalHtml && (
|
|
<ScrollArea className="max-h-[60vh] rounded-md border" viewportClassName="p-3">
|
|
<div
|
|
className="prose prose-sm dark:prose-invert"
|
|
dangerouslySetInnerHTML={{ __html: withdrawalHtml }}
|
|
/>
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|