various fixes for checkout

This commit is contained in:
Codex Agent
2025-12-22 21:51:34 +01:00
parent c8f0f880d2
commit 0f2604309d
12 changed files with 681 additions and 277 deletions

View File

@@ -51,25 +51,50 @@ export function resolveCheckoutCsrfToken(): string {
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 getCookieValue('XSRF-TOKEN') ?? '';
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 csrfToken = resolveCheckoutCsrfToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-CSRF-TOKEN'] = csrfToken;
headers['X-XSRF-TOKEN'] = csrfToken;
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;
@@ -159,7 +184,15 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean;
export const PaymentStep: React.FC = () => {
const { t, i18n } = useTranslation('marketing');
const { selectedPackage, nextStep, paddleConfig, authUser, paymentCompleted, setPaymentCompleted } = useCheckoutWizard();
const {
selectedPackage,
nextStep,
paddleConfig,
authUser,
setPaymentCompleted,
checkoutSessionId,
setCheckoutSessionId,
} = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle');
const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false);
@@ -196,16 +229,11 @@ export const PaymentStep: React.FC = () => {
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 [pendingConfirmation, setPendingConfirmation] = useState<{
transactionId: string | null;
checkoutId: string | null;
} | null>(null);
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);
@@ -224,6 +252,7 @@ export const PaymentStep: React.FC = () => {
}
try {
await refreshCheckoutCsrfToken();
await fetch(`/checkout/session/${checkoutSessionId}/confirm`, {
method: 'POST',
headers: buildCheckoutHeaders(),
@@ -335,9 +364,9 @@ export const PaymentStep: React.FC = () => {
setConsentError(null);
setFreeActivationBusy(true);
setAwaitingConfirmation(false);
try {
await refreshCheckoutCsrfToken();
const response = await fetch('/checkout/free-activate', {
method: 'POST',
headers: buildCheckoutHeaders(),
@@ -392,10 +421,9 @@ export const PaymentStep: React.FC = () => {
setMessage(t('checkout.payment_step.paddle_preparing'));
setInlineActive(false);
setCheckoutSessionId(null);
setAwaitingConfirmation(false);
setConfirmationElapsedMs(0);
try {
await refreshCheckoutCsrfToken();
const inlineSupported = initialised && !!paddleConfig?.client_token;
if (typeof window !== 'undefined') {
@@ -549,24 +577,23 @@ export const PaymentStep: React.FC = () => {
setStatus('processing');
setMessage(t('checkout.payment_step.processing_confirmation'));
setInlineActive(false);
setAwaitingConfirmation(true);
setPaymentCompleted(false);
setPendingConfirmation({ transactionId, checkoutId });
toast.success(t('checkout.payment_step.toast_success'));
setPaymentCompleted(true);
nextStep();
}
if (event.name === 'checkout.closed' && !awaitingConfirmation) {
if (event.name === 'checkout.closed') {
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);
}
};
@@ -617,7 +644,7 @@ export const PaymentStep: React.FC = () => {
return () => {
cancelled = true;
};
}, [awaitingConfirmation, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
}, [nextStep, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
useEffect(() => {
setPaymentCompleted(false);
@@ -625,8 +652,6 @@ export const PaymentStep: React.FC = () => {
setStatus('idle');
setMessage('');
setInlineActive(false);
setAwaitingConfirmation(false);
setConfirmationElapsedMs(0);
setPendingConfirmation(null);
}, [selectedPackage?.id, setPaymentCompleted]);
@@ -639,118 +664,6 @@ export const PaymentStep: React.FC = () => {
setPendingConfirmation(null);
}, [checkoutSessionId, confirmCheckoutSession, pendingConfirmation]);
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();
@@ -796,19 +709,6 @@ export const PaymentStep: React.FC = () => {
}
}, [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 (
@@ -882,33 +782,6 @@ export const PaymentStep: React.FC = () => {
);
}
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">