Fix PayPal billing flow and mobile admin UX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 10:19:29 +01:00
parent c43327af74
commit 0d7a861875
39 changed files with 1630 additions and 253 deletions

View File

@@ -4,7 +4,7 @@ import { useCheckoutWizard } from "../WizardContext";
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react";
import { AlertTriangle, CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface ConfirmationStepProps {
@@ -25,8 +25,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
checkoutSessionId,
setPaymentCompleted,
clearCheckoutSessionId,
checkoutActionUrl,
goToStep,
} = useCheckoutWizard();
const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>(
const [status, setStatus] = useState<'processing' | 'completed' | 'failed' | 'action_required'>(
checkoutSessionId ? 'processing' : 'completed',
);
const [elapsedMs, setElapsedMs] = useState(0);
@@ -79,6 +81,15 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
badge: 'bg-rose-50 text-rose-700 border-rose-200',
};
}
if (status === 'action_required') {
return {
label: t('checkout.confirmation_step.status_state.action_required'),
body: t('checkout.confirmation_step.status_body_action_required'),
tone: 'text-amber-600',
icon: AlertTriangle,
badge: 'bg-amber-50 text-amber-700 border-amber-200',
};
}
return {
label: t('checkout.confirmation_step.status_state.processing'),
body: t('checkout.confirmation_step.status_body_processing'),
@@ -88,7 +99,7 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
};
}, [status, t]);
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => {
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed' | 'action_required'> => {
if (!checkoutSessionId) {
return 'completed';
}
@@ -114,6 +125,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
return 'completed';
}
if (remoteStatus === 'requires_customer_action') {
return 'action_required';
}
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
clearCheckoutSessionId();
return 'failed';
@@ -204,10 +219,15 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
if (status === 'failed') {
return { payment: false, email: false, access: false };
}
if (status === 'action_required') {
return { payment: false, email: false, access: false };
}
return { payment: true, email: false, access: false };
}, [status]);
const showManualActions = status === 'processing' && elapsedMs >= 30000;
const showActionRequired = status === 'action_required';
const showFailedActions = status === 'failed';
const StatusIcon = statusCopy.icon;
const handleStatusRetry = useCallback(async () => {
@@ -227,6 +247,18 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
}
}, []);
const handleContinueCheckout = useCallback(() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
goToStep('payment');
}, [checkoutActionUrl, goToStep]);
const handleBackToPayment = useCallback(() => {
goToStep('payment');
}, [goToStep]);
return (
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
@@ -314,6 +346,29 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
</div>
</div>
)}
{showActionRequired && (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4 text-sm text-amber-900">
<p>{t('checkout.confirmation_step.status_action_hint')}</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button type="button" onClick={handleContinueCheckout}>
{t('checkout.confirmation_step.status_action_button')}
</Button>
<Button type="button" variant="ghost" onClick={handleBackToPayment}>
{t('checkout.confirmation_step.status_action_back')}
</Button>
</div>
</div>
)}
{showFailedActions && (
<div className="rounded-lg border border-rose-200 bg-rose-50/60 p-4 text-sm text-rose-900">
<p>{t('checkout.confirmation_step.status_failed_hint')}</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button type="button" onClick={handleBackToPayment}>
{t('checkout.confirmation_step.status_failed_back')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -108,6 +108,34 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string {
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;
@@ -134,8 +162,9 @@ async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.p
components: 'buttons',
});
if (options.locale) {
params.set('locale', options.locale);
const paypalLocale = resolvePayPalLocale(options.locale);
if (paypalLocale) {
params.set('locale', paypalLocale);
}
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
@@ -173,6 +202,9 @@ export const PaymentStep: React.FC = () => {
setPaymentCompleted,
checkoutSessionId,
setCheckoutSessionId,
checkoutActionUrl,
setCheckoutActionUrl,
clearCheckoutActionUrl,
paypalConfig,
} = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle');
@@ -292,18 +324,18 @@ export const PaymentStep: React.FC = () => {
}
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
useEffect(() => {
if (couponCode && selectedPackage) {
applyCoupon(couponCode);
}
}, [applyCoupon, couponCode, selectedPackage]);
useEffect(() => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(null);
}, [selectedPackage?.id]);
useEffect(() => {
if (selectedPackage) {
clearCheckoutActionUrl();
}
}, [clearCheckoutActionUrl, selectedPackage?.id]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
@@ -343,6 +375,7 @@ export const PaymentStep: React.FC = () => {
setConsentError(null);
setFreeActivationBusy(true);
clearCheckoutActionUrl();
try {
await refreshCheckoutCsrfToken();
@@ -469,6 +502,7 @@ export const PaymentStep: React.FC = () => {
setMessage(t('checkout.payment_step.paypal_preparing'));
setPaymentCompleted(false);
setCheckoutSessionId(null);
setCheckoutActionUrl(null);
await refreshCheckoutCsrfToken();
@@ -498,6 +532,11 @@ export const PaymentStep: React.FC = () => {
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.');
@@ -524,6 +563,11 @@ export const PaymentStep: React.FC = () => {
return;
}
if (payload?.status === 'requires_customer_action') {
nextStep();
return;
}
setStatus('error');
setMessage(t('checkout.payment_step.paypal_error'));
} catch (error) {
@@ -554,7 +598,7 @@ export const PaymentStep: React.FC = () => {
paypalButtonsRef.current.close();
}
};
}, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]);
}, [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) {
@@ -593,6 +637,12 @@ export const PaymentStep: React.FC = () => {
}
}, [couponPreview, trackEvent]);
const handleOpenPayPal = useCallback(() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
}
}, [checkoutActionUrl]);
const openWithdrawalModal = useCallback(async () => {
setShowWithdrawalModal(true);
@@ -771,6 +821,21 @@ export const PaymentStep: React.FC = () => {
<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>