various fixes for checkout
This commit is contained in:
@@ -29,7 +29,7 @@ i18n
|
||||
},
|
||||
backend: {
|
||||
// Cache-bust to ensure fresh translations when files change.
|
||||
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250204',
|
||||
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20251222',
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
|
||||
@@ -32,7 +32,17 @@ type SharedPageProps = {
|
||||
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
|
||||
const metaCsrfToken = () =>
|
||||
(document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
|
||||
|
||||
const xsrfCookieToken = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
};
|
||||
|
||||
export default function LoginForm({ onSuccess, canResetPassword = true, locale, packageId }: LoginFormProps) {
|
||||
const page = usePage<SharedPageProps>();
|
||||
@@ -91,12 +101,19 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale,
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const cookieToken = xsrfCookieToken();
|
||||
const metaToken = metaCsrfToken();
|
||||
|
||||
const response = await fetch(loginEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"X-CSRF-TOKEN": csrfToken(),
|
||||
...(cookieToken
|
||||
? { "X-XSRF-TOKEN": cookieToken }
|
||||
: metaToken
|
||||
? { "X-CSRF-TOKEN": metaToken }
|
||||
: {}),
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -45,18 +45,12 @@ const getCookieValue = (name: string): string | null => {
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const resolveCsrfToken = (): string => {
|
||||
const resolveMetaCsrfToken = (): string => {
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const metaToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
|
||||
if (metaToken && metaToken.length > 0) {
|
||||
return metaToken;
|
||||
}
|
||||
|
||||
return getCookieValue('XSRF-TOKEN') ?? '';
|
||||
return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '';
|
||||
};
|
||||
|
||||
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
|
||||
@@ -180,12 +174,13 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
setIsSubmitting(true);
|
||||
clearErrors();
|
||||
|
||||
const csrfToken = resolveCsrfToken();
|
||||
const metaToken = resolveMetaCsrfToken();
|
||||
const cookieToken = getCookieValue('XSRF-TOKEN');
|
||||
const body = {
|
||||
...data,
|
||||
locale: resolvedLocale,
|
||||
package_id: data.package_id ?? packageId ?? null,
|
||||
_token: csrfToken,
|
||||
_token: metaToken || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -194,8 +189,11 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-XSRF-TOKEN': csrfToken,
|
||||
...(cookieToken
|
||||
? { 'X-XSRF-TOKEN': cookieToken }
|
||||
: metaToken
|
||||
? { 'X-CSRF-TOKEN': metaToken }
|
||||
: {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -11,6 +11,7 @@ interface CheckoutState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
}
|
||||
|
||||
interface CheckoutWizardContextType {
|
||||
@@ -25,6 +26,7 @@ interface CheckoutWizardContextType {
|
||||
client_token?: string | null;
|
||||
} | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
selectPackage: (pkg: CheckoutPackage) => void;
|
||||
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
||||
setAuthUser: (user: unknown) => void;
|
||||
@@ -38,6 +40,8 @@ interface CheckoutWizardContextType {
|
||||
setError: (error: string | null) => void;
|
||||
resetPaymentState: () => void;
|
||||
setPaymentCompleted: (completed: boolean) => void;
|
||||
setCheckoutSessionId: (sessionId: string | null) => void;
|
||||
clearCheckoutSessionId: () => void;
|
||||
}
|
||||
|
||||
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
||||
@@ -52,6 +56,7 @@ const initialState: CheckoutState = {
|
||||
loading: false,
|
||||
error: null,
|
||||
paymentCompleted: false,
|
||||
checkoutSessionId: null,
|
||||
};
|
||||
|
||||
type CheckoutAction =
|
||||
@@ -63,7 +68,8 @@ type CheckoutAction =
|
||||
| { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean };
|
||||
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean }
|
||||
| { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null };
|
||||
|
||||
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
||||
switch (action.type) {
|
||||
@@ -99,6 +105,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
|
||||
return { ...state, error: action.payload };
|
||||
case 'SET_PAYMENT_COMPLETED':
|
||||
return { ...state, paymentCompleted: action.payload };
|
||||
case 'SET_CHECKOUT_SESSION_ID':
|
||||
return { ...state, checkoutSessionId: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -135,6 +143,7 @@ export function CheckoutWizardProvider({
|
||||
isAuthenticated: initialIsAuthenticated || Boolean(initialAuthUser),
|
||||
};
|
||||
|
||||
const checkoutSessionStorageKey = 'checkout-session-id';
|
||||
|
||||
const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
|
||||
|
||||
@@ -151,10 +160,15 @@ export function CheckoutWizardProvider({
|
||||
if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
|
||||
} else {
|
||||
localStorage.removeItem('checkout-wizard-state');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore checkout state:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore checkout state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const storedSession = localStorage.getItem(checkoutSessionStorageKey);
|
||||
if (storedSession) {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: storedSession });
|
||||
}
|
||||
}, [initialPackage]);
|
||||
|
||||
@@ -173,6 +187,14 @@ export function CheckoutWizardProvider({
|
||||
}
|
||||
}, [state.currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.checkoutSessionId) {
|
||||
localStorage.setItem(checkoutSessionStorageKey, state.checkoutSessionId);
|
||||
} else {
|
||||
localStorage.removeItem(checkoutSessionStorageKey);
|
||||
}
|
||||
}, [state.checkoutSessionId]);
|
||||
|
||||
const selectPackage = useCallback((pkg: CheckoutPackage) => {
|
||||
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
|
||||
}, []);
|
||||
@@ -214,12 +236,21 @@ export function CheckoutWizardProvider({
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
}, []);
|
||||
|
||||
const setPaymentCompleted = useCallback((completed: boolean) => {
|
||||
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: completed });
|
||||
}, []);
|
||||
|
||||
const setCheckoutSessionId = useCallback((sessionId: string | null) => {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: sessionId });
|
||||
}, []);
|
||||
|
||||
const clearCheckoutSessionId = useCallback(() => {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
}, []);
|
||||
|
||||
const cancelCheckout = useCallback(() => {
|
||||
// Track abandoned checkout (fire and forget)
|
||||
if (state.authUser || state.selectedPackage) {
|
||||
@@ -241,9 +272,10 @@ export function CheckoutWizardProvider({
|
||||
|
||||
// State aus localStorage entfernen
|
||||
localStorage.removeItem('checkout-wizard-state');
|
||||
localStorage.removeItem(checkoutSessionStorageKey);
|
||||
// Zur Package-Übersicht zurückleiten
|
||||
window.location.href = '/packages';
|
||||
}, [state]);
|
||||
}, [state, checkoutSessionStorageKey]);
|
||||
|
||||
const value: CheckoutWizardContextType = {
|
||||
state,
|
||||
@@ -254,6 +286,7 @@ export function CheckoutWizardProvider({
|
||||
authUser: state.authUser,
|
||||
paddleConfig: paddle ?? null,
|
||||
paymentCompleted: state.paymentCompleted,
|
||||
checkoutSessionId: state.checkoutSessionId,
|
||||
selectPackage,
|
||||
setSelectedPackage,
|
||||
setAuthUser,
|
||||
@@ -267,6 +300,8 @@ export function CheckoutWizardProvider({
|
||||
setError,
|
||||
resetPaymentState,
|
||||
setPaymentCompleted,
|
||||
setCheckoutSessionId,
|
||||
clearCheckoutSessionId,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { CheckoutWizardProvider } from '../WizardContext';
|
||||
import { ConfirmationStep } from '../steps/ConfirmationStep';
|
||||
|
||||
const basePackage = {
|
||||
id: 1,
|
||||
name: 'Starter',
|
||||
price: 49,
|
||||
type: 'endcustomer',
|
||||
paddle_price_id: 'pri_test_123',
|
||||
};
|
||||
|
||||
describe('ConfirmationStep', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders the confirmation summary sections', () => {
|
||||
render(
|
||||
<CheckoutWizardProvider initialPackage={basePackage} packageOptions={[basePackage]}>
|
||||
<ConfirmationStep />
|
||||
</CheckoutWizardProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('checkout.confirmation_step.status_title')).toBeInTheDocument();
|
||||
expect(screen.getByText('checkout.confirmation_step.package_title')).toBeInTheDocument();
|
||||
expect(screen.getByText('checkout.confirmation_step.actions_title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe('resolveCheckoutCsrfToken', () => {
|
||||
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
});
|
||||
|
||||
it('prefers the meta csrf token when present', () => {
|
||||
it('prefers the XSRF-TOKEN cookie when present', () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'csrf-token');
|
||||
meta.setAttribute('content', 'meta-token');
|
||||
@@ -33,12 +33,15 @@ describe('resolveCheckoutCsrfToken', () => {
|
||||
|
||||
document.cookie = 'XSRF-TOKEN=cookie-token';
|
||||
|
||||
expect(resolveCheckoutCsrfToken()).toBe('meta-token');
|
||||
});
|
||||
|
||||
it('falls back to the XSRF-TOKEN cookie when meta is missing', () => {
|
||||
document.cookie = 'XSRF-TOKEN=cookie-token';
|
||||
|
||||
expect(resolveCheckoutCsrfToken()).toBe('cookie-token');
|
||||
});
|
||||
|
||||
it('falls back to the meta csrf token when cookie is missing', () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'csrf-token');
|
||||
meta.setAttribute('content', 'meta-token');
|
||||
document.head.appendChild(meta);
|
||||
|
||||
expect(resolveCheckoutCsrfToken()).toBe('meta-token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCheckoutWizard } from "../WizardContext";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CalendarDays, QrCode, ClipboardList, Smartphone, Sparkles } from "lucide-react";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
onViewProfile?: () => void;
|
||||
onGoToAdmin?: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile, onGoToAdmin }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { selectedPackage } = useCheckoutWizard();
|
||||
const {
|
||||
selectedPackage,
|
||||
checkoutSessionId,
|
||||
setPaymentCompleted,
|
||||
clearCheckoutSessionId,
|
||||
} = useCheckoutWizard();
|
||||
const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>(
|
||||
checkoutSessionId ? 'processing' : 'completed',
|
||||
);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const handleProfile = React.useCallback(() => {
|
||||
if (typeof onViewProfile === 'function') {
|
||||
onViewProfile();
|
||||
@@ -20,8 +38,143 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
}
|
||||
window.location.href = '/settings/profile';
|
||||
}, [onViewProfile]);
|
||||
const handleAdmin = React.useCallback(() => {
|
||||
if (typeof onGoToAdmin === 'function') {
|
||||
onGoToAdmin();
|
||||
return;
|
||||
}
|
||||
window.location.href = '/event-admin';
|
||||
}, [onGoToAdmin]);
|
||||
|
||||
const packageName = selectedPackage?.name ?? '';
|
||||
const packagePrice = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
return '';
|
||||
}
|
||||
return selectedPackage.price === 0 ? t('packages.free') : currencyFormatter.format(selectedPackage.price);
|
||||
}, [selectedPackage, t]);
|
||||
const packageType = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
return '';
|
||||
}
|
||||
return selectedPackage.type === 'reseller' ? t('packages.subscription') : t('packages.one_time');
|
||||
}, [selectedPackage, t]);
|
||||
|
||||
const statusCopy = useMemo(() => {
|
||||
if (status === 'completed') {
|
||||
return {
|
||||
label: t('checkout.confirmation_step.status_state.completed'),
|
||||
body: t('checkout.confirmation_step.status_body_completed'),
|
||||
tone: 'text-emerald-600',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
};
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return {
|
||||
label: t('checkout.confirmation_step.status_state.failed'),
|
||||
body: t('checkout.confirmation_step.status_body_failed'),
|
||||
tone: 'text-rose-600',
|
||||
icon: XCircle,
|
||||
badge: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('checkout.confirmation_step.status_state.processing'),
|
||||
body: t('checkout.confirmation_step.status_body_processing'),
|
||||
tone: 'text-amber-600',
|
||||
icon: LoaderCircle,
|
||||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
};
|
||||
}, [status, t]);
|
||||
|
||||
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => {
|
||||
if (!checkoutSessionId) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/checkout/session/${checkoutSessionId}/status`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const remoteStatus = typeof payload?.status === 'string' ? payload.status : null;
|
||||
|
||||
if (remoteStatus === 'completed') {
|
||||
setPaymentCompleted(true);
|
||||
clearCheckoutSessionId();
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
|
||||
clearCheckoutSessionId();
|
||||
return 'failed';
|
||||
}
|
||||
} catch (error) {
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
return 'processing';
|
||||
}, [checkoutSessionId, clearCheckoutSessionId, setPaymentCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutSessionId) {
|
||||
setStatus('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus = await checkSessionStatus();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(nextStatus);
|
||||
if (nextStatus === 'processing' && typeof window !== 'undefined') {
|
||||
timeoutId = window.setTimeout(poll, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId && typeof window !== 'undefined') {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [checkSessionStatus, checkoutSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'processing' || typeof window === 'undefined') {
|
||||
setElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const onboardingItems = [
|
||||
{
|
||||
@@ -38,85 +191,219 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
},
|
||||
] as const;
|
||||
|
||||
const statusItems = [
|
||||
{ key: 'payment', icon: CheckCircle2 },
|
||||
{ key: 'email', icon: MailCheck },
|
||||
{ key: 'access', icon: ShieldCheck },
|
||||
] as const;
|
||||
|
||||
const statusProgress = useMemo(() => {
|
||||
if (status === 'completed') {
|
||||
return { payment: true, email: true, access: true };
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return { payment: false, email: false, access: false };
|
||||
}
|
||||
return { payment: true, email: false, access: false };
|
||||
}, [status]);
|
||||
|
||||
const showManualActions = status === 'processing' && elapsedMs >= 30000;
|
||||
const StatusIcon = statusCopy.icon;
|
||||
|
||||
const handleStatusRetry = useCallback(async () => {
|
||||
if (checking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
const nextStatus = await checkSessionStatus();
|
||||
setStatus(nextStatus);
|
||||
setChecking(false);
|
||||
}, [checkSessionStatus, checking]);
|
||||
|
||||
const handlePageRefresh = useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t('checkout.confirmation_step.hero_badge')}
|
||||
</Badge>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="checkout.confirmation_step.package_summary"
|
||||
components={{ strong: <span className="font-semibold" /> }}
|
||||
values={{ name: packageName }}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
|
||||
<p>{t('checkout.confirmation_step.hero_next')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card/60 p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('checkout.confirmation_step.onboarding_title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.confirmation_step.onboarding_subtitle')}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{t('checkout.confirmation_step.onboarding_badge')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
{onboardingItems.map(({ key, icon: Icon }) => (
|
||||
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
|
||||
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t('checkout.confirmation_step.hero_badge')}
|
||||
</Badge>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="checkout.confirmation_step.package_summary"
|
||||
components={{ strong: <span className="font-semibold" /> }}
|
||||
values={{ name: packageName }}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
|
||||
<p>{t('checkout.confirmation_step.hero_next')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-muted/40 bg-card/70 shadow-sm">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('checkout.confirmation_step.status_title')}</CardTitle>
|
||||
<CardDescription>{statusCopy.body}</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 border px-2 py-1 text-xs font-medium',
|
||||
statusCopy.badge,
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn('h-3.5 w-3.5', status === 'processing' && 'animate-spin')} />
|
||||
{statusCopy.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{statusItems.map(({ key, icon: Icon }) => {
|
||||
const active = statusProgress[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'rounded-lg border p-4 shadow-inner transition',
|
||||
active ? 'border-primary/20 bg-primary/5' : 'border-muted bg-background/60 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-3 inline-flex rounded-full p-2", active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{t(`checkout.confirmation_step.status_items.${key}.title`)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`checkout.confirmation_step.status_items.${key}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showManualActions && (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
<p>{t('checkout.confirmation_step.status_manual_hint')}</p>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button type="button" variant="outline" onClick={handleStatusRetry} disabled={checking}>
|
||||
{checking && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('checkout.confirmation_step.status_retry')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handlePageRefresh}>
|
||||
{t('checkout.confirmation_step.status_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-muted/40 bg-card/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm uppercase tracking-wide text-muted-foreground">
|
||||
{t('checkout.confirmation_step.onboarding_title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('checkout.confirmation_step.onboarding_subtitle')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{t('checkout.confirmation_step.onboarding_badge')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
{onboardingItems.map(({ key, icon: Icon }) => (
|
||||
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
|
||||
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/30 p-6 shadow-inner">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<aside className="space-y-6">
|
||||
<Card className="border-muted/40 bg-card shadow-sm">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base">{t('checkout.confirmation_step.package_title')}</CardTitle>
|
||||
<CardDescription>{t('checkout.confirmation_step.package_body')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-4 shadow-inner">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('checkout.confirmation_step.package_label')}
|
||||
</p>
|
||||
<p className="mt-1 text-base font-semibold text-foreground">{packageName}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="uppercase tracking-wide text-[10px]">
|
||||
{packageType}
|
||||
</Badge>
|
||||
<span>{packagePrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('checkout.confirmation_step.email_followup')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-muted/40 bg-muted/20 shadow-inner">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-sm font-semibold text-foreground">
|
||||
{t('checkout.confirmation_step.control_center_title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
{t('checkout.confirmation_step.control_center_body')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
{t('checkout.confirmation_step.control_center_hint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
{t('checkout.confirmation_step.control_center_hint')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleProfile}>
|
||||
{t('checkout.confirmation_step.open_profile')}
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="border-muted/40 bg-card shadow-sm">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-sm font-semibold">{t('checkout.confirmation_step.actions_title')}</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
{t('checkout.confirmation_step.actions_body')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
|
||||
<Button variant="outline" onClick={handleProfile}>
|
||||
{t('checkout.confirmation_step.open_profile')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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