Files
fotospiel-app/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
Codex Agent 0d7a861875
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix PayPal billing flow and mobile admin UX
2026-02-05 10:19:29 +01:00

976 lines
33 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';
import { useAnalytics } from '@/hooks/useAnalytics';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
declare global {
interface Window {
paypal?: {
Buttons: (options: Record<string, unknown>) => {
render: (selector: HTMLElement | string) => Promise<void>;
close?: () => void;
};
};
}
}
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
const PAYPAL_SDK_BASE = 'https://www.paypal.com/sdk/js';
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<void> {
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 resolveCheckoutLocale(rawLocale?: string | null): string {
if (!rawLocale) {
return 'en';
}
const normalized = rawLocale.toLowerCase();
const short = normalized.split('-')[0];
return short || 'en';
}
const PAYPAL_LOCALE_FALLBACKS: Record<string, string> = {
de: 'DE',
en: 'US',
};
function resolvePayPalLocale(rawLocale?: string | null): string | null {
if (!rawLocale) {
return null;
}
const trimmed = rawLocale.trim();
if (trimmed.length === 0) {
return null;
}
const normalized = trimmed.replace('_', '-');
const parts = normalized.split('-').filter(Boolean);
if (parts.length >= 2) {
return `${parts[0].toLowerCase()}_${parts[1].toUpperCase()}`;
}
const language = parts[0].toLowerCase();
const region = PAYPAL_LOCALE_FALLBACKS[language];
return region ? `${language}_${region}` : null;
}
type PayPalSdkOptions = {
clientId: string;
currency: string;
intent: string;
locale?: string | null;
};
let paypalLoaderPromise: Promise<typeof window.paypal | null> | null = null;
let paypalLoaderKey: string | null = null;
async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.paypal | null> {
if (typeof window === 'undefined') {
return null;
}
if (window.paypal) {
return window.paypal;
}
const params = new URLSearchParams({
'client-id': options.clientId,
currency: options.currency,
intent: options.intent,
components: 'buttons',
});
const paypalLocale = resolvePayPalLocale(options.locale);
if (paypalLocale) {
params.set('locale', paypalLocale);
}
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
if (paypalLoaderPromise && paypalLoaderKey === src) {
return paypalLoaderPromise;
}
paypalLoaderKey = src;
paypalLoaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => resolve(window.paypal ?? null);
script.onerror = (error) => reject(error);
document.head.appendChild(script);
}).catch((error) => {
console.error('Failed to load PayPal SDK', error);
paypalLoaderPromise = null;
return null;
});
return paypalLoaderPromise;
}
export const PaymentStep: React.FC = () => {
const { t, i18n } = useTranslation('marketing');
const { trackEvent } = useAnalytics();
const {
selectedPackage,
nextStep,
authUser,
isAuthenticated,
goToStep,
setPaymentCompleted,
checkoutSessionId,
setCheckoutSessionId,
checkoutActionUrl,
setCheckoutActionUrl,
clearCheckoutActionUrl,
paypalConfig,
} = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle');
const [message, setMessage] = useState<string>('');
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 [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 [freeActivationBusy, setFreeActivationBusy] = useState(false);
const paypalContainerRef = useRef<HTMLDivElement | null>(null);
const paypalButtonsRef = useRef<{ close?: () => void } | null>(null);
const paypalActionsRef = useRef<{ enable: () => void; disable: () => void } | null>(null);
const checkoutSessionRef = useRef<string | null>(checkoutSessionId);
const acceptedTermsRef = useRef<boolean>(acceptedTerms);
const couponCodeRef = useRef<string | null>(null);
useEffect(() => {
checkoutSessionRef.current = checkoutSessionId;
}, [checkoutSessionId]);
useEffect(() => {
acceptedTermsRef.current = acceptedTerms;
}, [acceptedTerms]);
useEffect(() => {
couponCodeRef.current = couponPreview?.coupon.code ?? null;
}, [couponPreview]);
const checkoutLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
return resolveCheckoutLocale(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'));
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(() => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(null);
}, [selectedPackage?.id]);
useEffect(() => {
if (selectedPackage) {
clearCheckoutActionUrl();
}
}, [clearCheckoutActionUrl, 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);
clearCheckoutActionUrl();
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: checkoutLocale,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paypal_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.paypal_error');
setConsentError(fallbackMessage);
toast.error(fallbackMessage);
} finally {
setFreeActivationBusy(false);
}
};
const handlePayPalCapture = useCallback(async (orderId: string) => {
const sessionId = checkoutSessionRef.current;
if (!sessionId) {
throw new Error('Missing checkout session');
}
await refreshCheckoutCsrfToken();
const response = await fetch('/paypal/capture-order', {
method: 'POST',
headers: buildCheckoutHeaders(),
credentials: 'same-origin',
body: JSON.stringify({
checkout_session_id: sessionId,
order_id: orderId,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || t('checkout.payment_step.paypal_error'));
}
return payload;
}, [t]);
useEffect(() => {
if (!selectedPackage || isFree) {
return;
}
const clientId = paypalConfig?.client_id ?? null;
if (!clientId) {
setStatus('error');
setMessage(t('checkout.payment_step.paypal_not_configured'));
return;
}
let cancelled = false;
const initButtons = async () => {
const paypal = await loadPayPalSdk({
clientId,
currency: paypalConfig?.currency ?? 'EUR',
intent: paypalConfig?.intent ?? 'capture',
locale: paypalConfig?.locale ?? checkoutLocale,
});
if (cancelled || !paypal || !paypalContainerRef.current) {
return;
}
if (paypalButtonsRef.current?.close) {
paypalButtonsRef.current.close();
}
paypalContainerRef.current.innerHTML = '';
paypalButtonsRef.current = paypal.Buttons({
onInit: (_data: unknown, actions: { enable: () => void; disable: () => void }) => {
paypalActionsRef.current = actions;
if (!acceptedTermsRef.current) {
actions.disable();
}
},
createOrder: async () => {
if (!selectedPackage) {
throw new Error('Missing package');
}
if (!isAuthenticated || !authUser) {
const authMessage = t('checkout.payment_step.auth_required');
setStatus('error');
setMessage(authMessage);
toast.error(authMessage);
goToStep('auth');
throw new Error(authMessage);
}
if (!acceptedTermsRef.current) {
const consentMessage = t('checkout.legal.checkbox_terms_error');
setConsentError(consentMessage);
throw new Error(consentMessage);
}
setConsentError(null);
setStatus('processing');
setMessage(t('checkout.payment_step.paypal_preparing'));
setPaymentCompleted(false);
setCheckoutSessionId(null);
setCheckoutActionUrl(null);
await refreshCheckoutCsrfToken();
const response = await fetch('/paypal/create-order', {
method: 'POST',
headers: buildCheckoutHeaders(),
credentials: 'same-origin',
body: JSON.stringify({
package_id: selectedPackage.id,
locale: checkoutLocale,
coupon_code: couponCodeRef.current ?? undefined,
accepted_terms: acceptedTermsRef.current,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMessage = payload?.message || t('checkout.payment_step.paypal_error');
setStatus('error');
setMessage(errorMessage);
throw new Error(errorMessage);
}
if (payload?.checkout_session_id) {
setCheckoutSessionId(payload.checkout_session_id);
checkoutSessionRef.current = payload.checkout_session_id;
}
const approveUrl = typeof payload?.approve_url === 'string' ? payload.approve_url : null;
if (approveUrl) {
setCheckoutActionUrl(approveUrl);
}
const orderId = payload?.order_id;
if (!orderId) {
throw new Error('PayPal order ID missing.');
}
setStatus('ready');
setMessage(t('checkout.payment_step.paypal_ready'));
return orderId;
},
onApprove: async (data: { orderID?: string }) => {
if (!data?.orderID) {
throw new Error('Missing PayPal order ID.');
}
setStatus('processing');
setMessage(t('checkout.payment_step.processing_confirmation'));
try {
const payload = await handlePayPalCapture(data.orderID);
if (payload?.status === 'completed') {
setPaymentCompleted(true);
toast.success(t('checkout.payment_step.toast_success'));
nextStep();
return;
}
if (payload?.status === 'requires_customer_action') {
nextStep();
return;
}
setStatus('error');
setMessage(t('checkout.payment_step.paypal_error'));
} catch (error) {
console.error('Failed to capture PayPal order', error);
setStatus('error');
setMessage(t('checkout.payment_step.paypal_error'));
}
},
onCancel: () => {
setStatus('idle');
setMessage(t('checkout.payment_step.paypal_cancelled'));
},
onError: (error: unknown) => {
console.error('PayPal button error', error);
setStatus('error');
setMessage(t('checkout.payment_step.paypal_error'));
},
}) as { render: (selector: HTMLElement | string) => Promise<void>; close?: () => void };
await paypalButtonsRef.current.render(paypalContainerRef.current);
};
void initButtons();
return () => {
cancelled = true;
if (paypalButtonsRef.current?.close) {
paypalButtonsRef.current.close();
}
};
}, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutActionUrl, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]);
useEffect(() => {
if (paypalActionsRef.current) {
if (acceptedTerms) {
paypalActionsRef.current.enable();
} else {
paypalActionsRef.current.disable();
}
}
}, [acceptedTerms]);
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
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 handleOpenPayPal = useCallback(() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
}
}, [checkoutActionUrl]);
const openWithdrawalModal = useCallback(async () => {
setShowWithdrawalModal(true);
if (withdrawalHtml || withdrawalLoading) {
return;
}
setWithdrawalLoading(true);
setWithdrawalError(null);
try {
const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${checkoutLocale}`);
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);
}
}, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]);
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>
);
}
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 PayPalBadge = () => (
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
<span className="text-sm font-semibold tracking-wide">PayPal</span>
<span className="text-xs font-semibold">{t('checkout.payment_step.paypal_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">
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#0b1f4b] via-[#003087] to-[#009cde] 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">
<PayPalBadge />
<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-[#003087]"
/>
<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">
<div ref={paypalContainerRef} className={cn('min-h-[44px]', PRIMARY_CTA_STYLES)} />
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
{checkoutActionUrl && (
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full border-white/50 bg-transparent text-white hover:bg-white/15 hover:text-white"
onClick={handleOpenPayPal}
>
{t('checkout.payment_step.resume_paypal')}
</Button>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.resume_hint')}
</p>
</div>
)}
</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>
{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>
)}
<p className="text-xs text-muted-foreground sm:text-right">
{t('checkout.payment_step.paypal_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>
);
};