various fixes for checkout
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user