1088 lines
38 KiB
TypeScript
1088 lines
38 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';
|
|
|
|
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 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 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 csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
const response = await fetch('/checkout/free-activate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
},
|
|
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: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
},
|
|
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);
|
|
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);
|
|
}, [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>
|
|
);
|
|
};
|