|
|
|
|
@@ -158,6 +158,12 @@ 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 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);
|
|
|
|
|
@@ -253,13 +259,57 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}, [couponCode]);
|
|
|
|
|
|
|
|
|
|
const handleFreeActivation = async () => {
|
|
|
|
|
if (!selectedPackage) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
|
|
|
|
|
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPaymentCompleted(true);
|
|
|
|
|
nextStep();
|
|
|
|
|
setConsentError(null);
|
|
|
|
|
setFreeActivationBusy(true);
|
|
|
|
|
setAwaitingConfirmation(false);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
|
|
|
const response = await fetch('/checkout/free-activate', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
|
|
|
},
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
package_id: selectedPackage.id,
|
|
|
|
|
accepted_terms: acceptedTerms,
|
|
|
|
|
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_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.paddle_error');
|
|
|
|
|
setConsentError(fallbackMessage);
|
|
|
|
|
toast.error(fallbackMessage);
|
|
|
|
|
} finally {
|
|
|
|
|
setFreeActivationBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startPaddleCheckout = async () => {
|
|
|
|
|
@@ -282,6 +332,9 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.paddle_preparing'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setCheckoutSessionId(null);
|
|
|
|
|
setAwaitingConfirmation(false);
|
|
|
|
|
setConfirmationElapsedMs(0);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
|
|
|
|
@@ -297,7 +350,65 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (inlineSupported) {
|
|
|
|
|
const response = await fetch('/paddle/create-checkout', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
package_id: selectedPackage.id,
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
coupon_code: couponPreview?.coupon.code ?? undefined,
|
|
|
|
|
accepted_terms: acceptedTerms,
|
|
|
|
|
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
|
|
|
|
inline: inlineSupported,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rawBody = await response.text();
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
|
|
|
|
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data: { checkout_url?: string; message?: string } | null = null;
|
|
|
|
|
try {
|
|
|
|
|
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
|
|
|
|
} catch (parseError) {
|
|
|
|
|
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
|
|
|
|
data = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data && typeof (data as { checkout_session_id?: string }).checkout_session_id === 'string') {
|
|
|
|
|
setCheckoutSessionId((data as { checkout_session_id?: string }).checkout_session_id ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
|
|
|
|
|
|
|
|
|
|
if (!checkoutUrl) {
|
|
|
|
|
const trimmed = rawBody.trim();
|
|
|
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
|
|
|
checkoutUrl = trimmed;
|
|
|
|
|
} else if (trimmed.startsWith('<')) {
|
|
|
|
|
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
|
|
|
|
|
if (match) {
|
|
|
|
|
checkoutUrl = match[0];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok || !checkoutUrl) {
|
|
|
|
|
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
|
|
|
|
if (response.ok && data && (data as { mode?: string }).mode === 'inline') {
|
|
|
|
|
checkoutUrl = null;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data && (data as { mode?: string }).mode === 'inline') {
|
|
|
|
|
const paddle = paddleRef.current;
|
|
|
|
|
|
|
|
|
|
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
|
|
|
|
@@ -305,31 +416,23 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inlinePayload: Record<string, unknown> = {
|
|
|
|
|
items: [
|
|
|
|
|
{
|
|
|
|
|
priceId: selectedPackage.paddle_price_id,
|
|
|
|
|
quantity: 1,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
items: (data as { items?: unknown[] }).items ?? [],
|
|
|
|
|
settings: {
|
|
|
|
|
displayMode: 'inline',
|
|
|
|
|
frameTarget: checkoutContainerClass,
|
|
|
|
|
frameInitialHeight: '550',
|
|
|
|
|
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
|
|
|
|
theme: 'light',
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
},
|
|
|
|
|
customData: {
|
|
|
|
|
package_id: String(selectedPackage.id),
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
accepted_terms: acceptedTerms ? '1' : '0',
|
|
|
|
|
accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const customerEmail = authUser?.email ?? null;
|
|
|
|
|
if (customerEmail) {
|
|
|
|
|
inlinePayload.customer = { email: customerEmail };
|
|
|
|
|
if ((data as { custom_data?: Record<string, unknown> }).custom_data) {
|
|
|
|
|
inlinePayload.customData = (data as { custom_data?: Record<string, unknown> }).custom_data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((data as { customer?: Record<string, unknown> }).customer) {
|
|
|
|
|
inlinePayload.customer = (data as { customer?: Record<string, unknown> }).customer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
@@ -356,54 +459,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/paddle/create-checkout', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
package_id: selectedPackage.id,
|
|
|
|
|
locale: paddleLocale,
|
|
|
|
|
coupon_code: couponPreview?.coupon.code ?? undefined,
|
|
|
|
|
accepted_terms: acceptedTerms,
|
|
|
|
|
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rawBody = await response.text();
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
|
|
|
|
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data: { checkout_url?: string; message?: string } | null = null;
|
|
|
|
|
try {
|
|
|
|
|
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
|
|
|
|
} catch (parseError) {
|
|
|
|
|
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
|
|
|
|
data = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
|
|
|
|
|
|
|
|
|
|
if (!checkoutUrl) {
|
|
|
|
|
const trimmed = rawBody.trim();
|
|
|
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
|
|
|
checkoutUrl = trimmed;
|
|
|
|
|
} else if (trimmed.startsWith('<')) {
|
|
|
|
|
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
|
|
|
|
|
if (match) {
|
|
|
|
|
checkoutUrl = match[0];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok || !checkoutUrl) {
|
|
|
|
|
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.open(checkoutUrl, '_blank', 'noopener');
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setStatus('ready');
|
|
|
|
|
@@ -434,14 +489,15 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.name === 'checkout.completed') {
|
|
|
|
|
setStatus('ready');
|
|
|
|
|
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setPaymentCompleted(true);
|
|
|
|
|
setAwaitingConfirmation(true);
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
toast.success(t('checkout.payment_step.toast_success'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.name === 'checkout.closed') {
|
|
|
|
|
if (event.name === 'checkout.closed' && !awaitingConfirmation) {
|
|
|
|
|
setStatus('idle');
|
|
|
|
|
setMessage('');
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
@@ -452,6 +508,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setAwaitingConfirmation(false);
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
@@ -502,12 +559,130 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
|
|
|
|
}, [awaitingConfirmation, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
setCheckoutSessionId(null);
|
|
|
|
|
setStatus('idle');
|
|
|
|
|
setMessage('');
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setAwaitingConfirmation(false);
|
|
|
|
|
setConfirmationElapsedMs(0);
|
|
|
|
|
}, [selectedPackage?.id, setPaymentCompleted]);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
@@ -553,6 +728,20 @@ 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 (
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
@@ -569,8 +758,78 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{requiresImmediateWaiver && (
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<Checkbox
|
|
|
|
|
id="checkout-waiver-free"
|
|
|
|
|
checked={acceptedWaiver}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
setAcceptedWaiver(Boolean(checked));
|
|
|
|
|
if (consentError) {
|
|
|
|
|
setConsentError(null);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div className="space-y-1 text-sm">
|
|
|
|
|
<Label htmlFor="checkout-waiver-free" className="cursor-pointer">
|
|
|
|
|
{t('checkout.legal.checkbox_digital_content_label')}
|
|
|
|
|
</Label>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{t('checkout.legal.hint_subscription_withdrawal')}
|
|
|
|
|
</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}>
|
|
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
onClick={handleFreeActivation}
|
|
|
|
|
disabled={freeActivationBusy || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
|
|
|
|
>
|
|
|
|
|
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
|
|
{t('checkout.payment_step.activate_package')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -578,6 +837,34 @@ 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">
|
|
|
|
|
<Icon className="h-4 w-4 text-white/80" />
|
|
|
|
|
|