Fix PayPal billing flow and mobile admin UX
This commit is contained in:
@@ -168,7 +168,10 @@ const WizardBody: React.FC<{
|
||||
return true;
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
|
||||
|
||||
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
|
||||
const shouldShowNextButton = useMemo(
|
||||
() => currentStep !== 'confirmation' && currentStep !== 'payment',
|
||||
[currentStep]
|
||||
);
|
||||
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CheckoutState {
|
||||
error: string | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
checkoutActionUrl: string | null;
|
||||
}
|
||||
|
||||
interface CheckoutWizardContextType {
|
||||
@@ -29,6 +30,7 @@ interface CheckoutWizardContextType {
|
||||
} | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
checkoutActionUrl: string | null;
|
||||
selectPackage: (pkg: CheckoutPackage) => void;
|
||||
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
||||
setAuthUser: (user: unknown) => void;
|
||||
@@ -44,6 +46,8 @@ interface CheckoutWizardContextType {
|
||||
setPaymentCompleted: (completed: boolean) => void;
|
||||
setCheckoutSessionId: (sessionId: string | null) => void;
|
||||
clearCheckoutSessionId: () => void;
|
||||
setCheckoutActionUrl: (url: string | null) => void;
|
||||
clearCheckoutActionUrl: () => void;
|
||||
}
|
||||
|
||||
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
||||
@@ -59,6 +63,7 @@ const initialState: CheckoutState = {
|
||||
error: null,
|
||||
paymentCompleted: false,
|
||||
checkoutSessionId: null,
|
||||
checkoutActionUrl: null,
|
||||
};
|
||||
|
||||
type CheckoutAction =
|
||||
@@ -71,7 +76,8 @@ type CheckoutAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean }
|
||||
| { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null };
|
||||
| { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null }
|
||||
| { type: 'SET_CHECKOUT_ACTION_URL'; payload: string | null };
|
||||
|
||||
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
||||
switch (action.type) {
|
||||
@@ -109,6 +115,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
|
||||
return { ...state, paymentCompleted: action.payload };
|
||||
case 'SET_CHECKOUT_SESSION_ID':
|
||||
return { ...state, checkoutSessionId: action.payload };
|
||||
case 'SET_CHECKOUT_ACTION_URL':
|
||||
return { ...state, checkoutActionUrl: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -148,6 +156,7 @@ export function CheckoutWizardProvider({
|
||||
};
|
||||
|
||||
const checkoutSessionStorageKey = 'checkout-session-id';
|
||||
const checkoutActionStorageKey = 'checkout-action-url';
|
||||
|
||||
const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
|
||||
|
||||
@@ -174,6 +183,11 @@ export function CheckoutWizardProvider({
|
||||
if (storedSession) {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: storedSession });
|
||||
}
|
||||
|
||||
const storedActionUrl = localStorage.getItem(checkoutActionStorageKey);
|
||||
if (storedActionUrl) {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: storedActionUrl });
|
||||
}
|
||||
}, [initialPackage]);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
@@ -199,6 +213,14 @@ export function CheckoutWizardProvider({
|
||||
}
|
||||
}, [state.checkoutSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.checkoutActionUrl) {
|
||||
localStorage.setItem(checkoutActionStorageKey, state.checkoutActionUrl);
|
||||
} else {
|
||||
localStorage.removeItem(checkoutActionStorageKey);
|
||||
}
|
||||
}, [state.checkoutActionUrl]);
|
||||
|
||||
const selectPackage = useCallback((pkg: CheckoutPackage) => {
|
||||
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
|
||||
}, []);
|
||||
@@ -241,6 +263,7 @@ export function CheckoutWizardProvider({
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const setPaymentCompleted = useCallback((completed: boolean) => {
|
||||
@@ -253,6 +276,15 @@ export function CheckoutWizardProvider({
|
||||
|
||||
const clearCheckoutSessionId = useCallback(() => {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const setCheckoutActionUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: url });
|
||||
}, []);
|
||||
|
||||
const clearCheckoutActionUrl = useCallback(() => {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const cancelCheckout = useCallback(() => {
|
||||
@@ -277,9 +309,10 @@ export function CheckoutWizardProvider({
|
||||
// State aus localStorage entfernen
|
||||
localStorage.removeItem('checkout-wizard-state');
|
||||
localStorage.removeItem(checkoutSessionStorageKey);
|
||||
localStorage.removeItem(checkoutActionStorageKey);
|
||||
// Zur Package-Übersicht zurückleiten
|
||||
window.location.href = '/packages';
|
||||
}, [state, checkoutSessionStorageKey]);
|
||||
}, [state, checkoutActionStorageKey, checkoutSessionStorageKey]);
|
||||
|
||||
const value: CheckoutWizardContextType = {
|
||||
state,
|
||||
@@ -291,6 +324,7 @@ export function CheckoutWizardProvider({
|
||||
paypalConfig: paypal ?? null,
|
||||
paymentCompleted: state.paymentCompleted,
|
||||
checkoutSessionId: state.checkoutSessionId,
|
||||
checkoutActionUrl: state.checkoutActionUrl,
|
||||
selectPackage,
|
||||
setSelectedPackage,
|
||||
setAuthUser,
|
||||
@@ -306,6 +340,8 @@ export function CheckoutWizardProvider({
|
||||
setPaymentCompleted,
|
||||
setCheckoutSessionId,
|
||||
clearCheckoutSessionId,
|
||||
setCheckoutActionUrl,
|
||||
clearCheckoutActionUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
expect(screen.queryByTestId('payment-step')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('only renders the next button on the payment step after the payment is completed', async () => {
|
||||
it('does not render the next button on the payment step', async () => {
|
||||
const paidPackage = { ...basePackage, id: 2, price: 99 };
|
||||
|
||||
render(
|
||||
@@ -113,12 +113,10 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
|
||||
await screen.findByTestId('payment-step');
|
||||
|
||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
||||
nextButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
|
||||
|
||||
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
|
||||
activatedButtons.forEach((button) => expect(button).toBeEnabled());
|
||||
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user