Updated checkout to wait for backend confirmation before advancing, added a “Processing payment…” state with retry/ refresh fallback, and now use Paddle totals/currency for purchase records + confirmation emails (with new email translations).

This commit is contained in:
Codex Agent
2025-12-22 09:06:48 +01:00
parent 41d29eb7d3
commit 84234bfb8e
36 changed files with 1742 additions and 187 deletions

View File

@@ -23,6 +23,7 @@ interface LoginFormProps {
onSuccess?: (userData: AuthUserPayload | null) => void;
canResetPassword?: boolean;
locale?: string;
packageId?: number | null;
}
type SharedPageProps = {
@@ -33,7 +34,7 @@ type FieldErrors = Record<string, string>;
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) {
export default function LoginForm({ onSuccess, canResetPassword = true, locale, packageId }: LoginFormProps) {
const page = usePage<SharedPageProps>();
const { t } = useTranslation("auth");
const resolvedLocale = locale ?? page.props.locale ?? "de";
@@ -103,6 +104,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
password: values.password,
remember: values.remember,
locale: resolvedLocale,
package_id: packageId ?? null,
}),
});

View File

@@ -85,15 +85,6 @@ const WizardBody: React.FC<{
const hasMountedRef = useRef(false);
const { trackEvent } = useAnalytics();
const isFreeSelected = useMemo(() => {
if (!selectedPackage) {
return false;
}
const priceValue = Number(selectedPackage.price);
return Number.isFinite(priceValue) && priceValue <= 0;
}, [selectedPackage]);
const stepConfig = useMemo(() =>
baseStepConfig.map(step => ({
id: step.id,
@@ -159,11 +150,11 @@ const WizardBody: React.FC<{
}
if (currentStep === 'payment') {
return isFreeSelected || paymentCompleted;
return paymentCompleted;
}
return true;
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
const highlightNextCta = currentStep === 'payment' && paymentCompleted;

View File

@@ -206,6 +206,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
<LoginForm
locale={locale}
onSuccess={handleLoginSuccess}
packageId={selectedPackage?.id ?? null}
/>
)}
</div>

View File

@@ -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" />

View File

@@ -6,7 +6,7 @@
<body>
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p>
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
<p>{{ __('emails.purchase.price', ['price' => $priceFormatted]) }}</p>
<p>{{ __('emails.purchase.activation') }}</p>
<p>{!! __('emails.purchase.footer') !!}</p>
</body>