From 0f2604309d77dcab7d628df072a292510a8f2acf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 22 Dec 2025 21:51:34 +0100 Subject: [PATCH] various fixes for checkout --- app/Http/Controllers/CheckoutController.php | 26 +- public/lang/de/marketing.json | 34 +- public/lang/en/marketing.json | 34 +- resources/js/i18n.ts | 2 +- resources/js/pages/auth/LoginForm.tsx | 21 +- resources/js/pages/auth/RegisterForm.tsx | 22 +- .../marketing/checkout/WizardContext.tsx | 45 +- .../ConfirmationStep.render.test.tsx | 31 ++ .../__tests__/PaymentStep.locale.test.ts | 17 +- .../checkout/steps/ConfirmationStep.tsx | 435 +++++++++++++++--- .../marketing/checkout/steps/PaymentStep.tsx | 219 ++------- .../CheckoutSessionLocalConfirmationTest.php | 72 +++ 12 files changed, 681 insertions(+), 277 deletions(-) create mode 100644 resources/js/pages/marketing/checkout/__tests__/ConfirmationStep.render.test.tsx create mode 100644 tests/Feature/CheckoutSessionLocalConfirmationTest.php diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 69cbcc8..bdb8c61 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -301,7 +301,31 @@ class CheckoutController extends Controller $session->save(); } - $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + if (app()->environment('local') + && $session->provider === CheckoutSession::PROVIDER_PADDLE + && ! in_array($session->status, [ + CheckoutSession::STATUS_COMPLETED, + CheckoutSession::STATUS_FAILED, + CheckoutSession::STATUS_CANCELLED, + ], true) + && ($transactionId || $checkoutId) + ) { + $sessions->markProcessing($session, array_filter([ + 'paddle_status' => 'completed', + 'paddle_transaction_id' => $transactionId, + 'paddle_local_confirmed_at' => now()->toIso8601String(), + ])); + + $assignment->finalise($session, [ + 'source' => 'paddle_local', + 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'provider_reference' => $transactionId ?? $checkoutId, + ]); + + $sessions->markCompleted($session); + } else { + $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + } $session->refresh(); diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 76b5018..fd84384 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -600,6 +600,33 @@ "hero_title": "Weiter geht's im Marketing-Dashboard", "hero_body": "Wir haben deinen Zugang aktiviert und Paddle synchronisiert. Mit diesen Aufgaben startest du direkt durch.", "hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.", + "status_title": "Bestellstatus", + "status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.", + "status_state": { + "processing": "Wird bestätigt", + "completed": "Bestätigt", + "failed": "Aktion nötig" + }, + "status_body_processing": "Wir synchronisieren dein Konto mit Paddle. Das kann einen Moment dauern.", + "status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.", + "status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.", + "status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.", + "status_retry": "Status prüfen", + "status_refresh": "Seite aktualisieren", + "status_items": { + "payment": { + "title": "Zahlung bestätigt", + "body": "Deine Paddle-Zahlung war erfolgreich." + }, + "email": { + "title": "Beleg versendet", + "body": "Die Bestätigungsmail ist unterwegs." + }, + "access": { + "title": "Zugang freigeschaltet", + "body": "Dashboard und PWA stehen bereit." + } + }, "onboarding_title": "Vorschau auf deine Onboarding-Schritte", "onboarding_subtitle": "Diese Aufgaben erwarten dich direkt nach dem Login.", "onboarding_badge": "Nächste Schritte", @@ -620,6 +647,11 @@ "control_center_title": "Event Control Center (PWA)", "control_center_body": "Alle Live-Aufgaben erledigst du später im Control Center – optimiert für Mobilgeräte und offlinefähig.", "control_center_hint": "Installiere die PWA direkt aus dem Dashboard.", + "package_title": "Dein Paket", + "package_body": "Dein Paket ist aktiviert und sofort einsatzbereit.", + "package_label": "Aktiviertes Paket", + "actions_title": "Nächste Schritte", + "actions_body": "Zum Adminbereich wechseln oder Profildaten prüfen.", "package_activated": "Ihr Paket '{name}' ist aktiviert.", "email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.", "open_profile": "Profil öffnen", @@ -1025,4 +1057,4 @@ "privacy": "Datenschutz", "terms": "AGB" } -} \ No newline at end of file +} diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 280fdc5..5b4183c 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -593,6 +593,33 @@ "hero_title": "You're ready for the Marketing Dashboard", "hero_body": "We activated your access and synced Paddle. Follow the checklist below to launch your first event.", "hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.", + "status_title": "Purchase status", + "status_subtitle": "We are finishing the handoff and syncing your account.", + "status_state": { + "processing": "Finalising", + "completed": "Confirmed", + "failed": "Needs attention" + }, + "status_body_processing": "We are syncing your account with Paddle. This can take a minute.", + "status_body_completed": "Everything is ready. Your account is fully unlocked.", + "status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.", + "status_manual_hint": "Still waiting? You can re-check the status or refresh the page.", + "status_retry": "Check status", + "status_refresh": "Refresh page", + "status_items": { + "payment": { + "title": "Payment confirmed", + "body": "Your Paddle payment was successful." + }, + "email": { + "title": "Receipt sent", + "body": "A confirmation email is on its way." + }, + "access": { + "title": "Access unlocked", + "body": "Your dashboard and PWA access are active." + } + }, "onboarding_title": "Preview your onboarding steps", "onboarding_subtitle": "These are the first tasks you'll see after logging in.", "onboarding_badge": "Next steps", @@ -613,6 +640,11 @@ "control_center_title": "Event Control Center (PWA)", "control_center_body": "You handle live moderation and uploads in the Control Center — mobile-first and offline-ready.", "control_center_hint": "Install the PWA directly from the dashboard.", + "package_title": "Your package", + "package_body": "Your plan is active and ready to use.", + "package_label": "Activated package", + "actions_title": "Next actions", + "actions_body": "Jump into your admin area or update profile details.", "package_activated": "Your package '{name}' is activated.", "email_sent": "We have sent you a confirmation email.", "open_profile": "Open Profile", @@ -1018,4 +1050,4 @@ "privacy": "Privacy", "terms": "Terms & Conditions" } -} \ No newline at end of file +} diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts index 821b404..590b4fa 100644 --- a/resources/js/i18n.ts +++ b/resources/js/i18n.ts @@ -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, diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index c80a0ac..eaf661f 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -32,7 +32,17 @@ type SharedPageProps = { type FieldErrors = Record; -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(); @@ -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({ diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index d9157f6..b47203c 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -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), diff --git a/resources/js/pages/marketing/checkout/WizardContext.tsx b/resources/js/pages/marketing/checkout/WizardContext.tsx index 3b2dd70..f1d0215 100644 --- a/resources/js/pages/marketing/checkout/WizardContext.tsx +++ b/resources/js/pages/marketing/checkout/WizardContext.tsx @@ -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(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 ( diff --git a/resources/js/pages/marketing/checkout/__tests__/ConfirmationStep.render.test.tsx b/resources/js/pages/marketing/checkout/__tests__/ConfirmationStep.render.test.tsx new file mode 100644 index 0000000..4faa145 --- /dev/null +++ b/resources/js/pages/marketing/checkout/__tests__/ConfirmationStep.render.test.tsx @@ -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( + + + , + ); + + 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(); + }); +}); diff --git a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts index df48a69..01e7a44 100644 --- a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts +++ b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts @@ -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'); + }); }); diff --git a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx index dba4903..773cdb7 100644 --- a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx @@ -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 = ({ onViewProfile }) => { +const currencyFormatter = new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, +}); + +export const ConfirmationStep: React.FC = ({ 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 = ({ 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 = ({ 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 ( -
-
-
-
- - - {t('checkout.confirmation_step.hero_badge')} - -
-

{t('checkout.confirmation_step.hero_title')}

-

- }} - values={{ name: packageName }} - /> -

-

{t('checkout.confirmation_step.hero_body')}

-
-
-
-

{t('checkout.confirmation_step.hero_next')}

-
-
-
- -
-
-
-

- {t('checkout.confirmation_step.onboarding_title')} -

-

{t('checkout.confirmation_step.onboarding_subtitle')}

-
- - {t('checkout.confirmation_step.onboarding_badge')} - -
-
- {onboardingItems.map(({ key, icon: Icon }) => ( -
-
- +
+
+
+
+
+ + + {t('checkout.confirmation_step.hero_badge')} + +
+

{t('checkout.confirmation_step.hero_title')}

+

+ }} + values={{ name: packageName }} + /> +

+

{t('checkout.confirmation_step.hero_body')}

-

- {t(`checkout.confirmation_step.onboarding_items.${key}.title`)} -

-

- {t(`checkout.confirmation_step.onboarding_items.${key}.body`)} -

- ))} +
+

{t('checkout.confirmation_step.hero_next')}

+
+
+ + + +
+
+ {t('checkout.confirmation_step.status_title')} + {statusCopy.body} +
+ + + {statusCopy.label} + +
+
+ +
+ {statusItems.map(({ key, icon: Icon }) => { + const active = statusProgress[key]; + return ( +
+
+ +
+

+ {t(`checkout.confirmation_step.status_items.${key}.title`)} +

+

+ {t(`checkout.confirmation_step.status_items.${key}.body`)} +

+
+ ); + })} +
+ {showManualActions && ( +
+

{t('checkout.confirmation_step.status_manual_hint')}

+
+ + +
+
+ )} +
+
+ + + +
+ + {t('checkout.confirmation_step.onboarding_title')} + + + {t('checkout.confirmation_step.onboarding_subtitle')} + +
+ + {t('checkout.confirmation_step.onboarding_badge')} + +
+ + {onboardingItems.map(({ key, icon: Icon }) => ( +
+
+ +
+

+ {t(`checkout.confirmation_step.onboarding_items.${key}.title`)} +

+

+ {t(`checkout.confirmation_step.onboarding_items.${key}.body`)} +

+
+ ))} +
+
-
-
-
-

+

-
- - {t('checkout.confirmation_step.control_center_hint')} -
-
-
+ + + +
+ + {t('checkout.confirmation_step.control_center_hint')} +
+
+ -
- -
+ + + {t('checkout.confirmation_step.actions_title')} + + {t('checkout.confirmation_step.actions_body')} + + + + + + + +
); }; diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index c77f1e5..a4d32fe 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -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 { + 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; 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('idle'); const [message, setMessage] = useState(''); const [initialised, setInitialised] = useState(false); @@ -196,16 +229,11 @@ export const PaymentStep: React.FC = () => { const RateLimitHelper = useRateLimitHelper('coupon'); const [voucherExpiry, setVoucherExpiry] = useState(null); const [isGiftVoucher, setIsGiftVoucher] = useState(false); - const [checkoutSessionId, setCheckoutSessionId] = useState(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(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 => { - 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) => { 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 ( -
-
- -
-

{t('checkout.payment_step.processing_title')}

-

{t('checkout.payment_step.processing_body')}

-
-
- - {showManualActions && ( -
-

{t('checkout.payment_step.processing_manual_hint')}

-
- - -
-
- )} -
- ); - } const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
diff --git a/tests/Feature/CheckoutSessionLocalConfirmationTest.php b/tests/Feature/CheckoutSessionLocalConfirmationTest.php new file mode 100644 index 0000000..a3c49e3 --- /dev/null +++ b/tests/Feature/CheckoutSessionLocalConfirmationTest.php @@ -0,0 +1,72 @@ +app['env'] = 'local'; + Mail::fake(); + Notification::fake(); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create(); + $package = Package::factory()->create([ + 'paddle_price_id' => 'pri_123', + 'paddle_product_id' => 'pro_123', + 'price' => 120, + ]); + + $sessions = app(CheckoutSessionService::class); + $session = $sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + $sessions->selectProvider($session, 'paddle'); + + $this->actingAs($user); + $this->withSession(['_token' => 'test-token']); + + $response = $this->postJson( + route('checkout.session.confirm', $session), + [ + 'transaction_id' => 'txn_123', + 'checkout_id' => 'chk_123', + ], + [ + 'X-CSRF-TOKEN' => 'test-token', + ] + ); + + $response->assertOk() + ->assertJsonPath('status', 'completed'); + + $this->assertDatabaseHas('checkout_sessions', [ + 'id' => $session->id, + 'status' => 'completed', + ]); + + $this->assertDatabaseHas('package_purchases', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => 'txn_123', + ]); + + $this->assertDatabaseHas('tenant_packages', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + ]); + } +}