From 89575c9fcbb7f96febb488c660fa17290b38e85c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:26:32 +0100 Subject: [PATCH] feat: add checkout page shell with auth guard and success polling --- app/checkout/page.tsx | 235 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 app/checkout/page.tsx diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx new file mode 100644 index 0000000..c994104 --- /dev/null +++ b/app/checkout/page.tsx @@ -0,0 +1,235 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' +import { motion } from 'framer-motion' +import { useAuth } from '@/lib/auth/context' +import { useSubscription } from '@/lib/swr/dashboard' +import { getSubscription } from '@/lib/api/billing' +import { PLAN_PRICES, TRAFFIC_TIERS } from '@/lib/plans' +import PlanSummary from '@/components/checkout/PlanSummary' +import PaymentForm from '@/components/checkout/PaymentForm' +import pulseLogo from '@/public/pulse_logo_no_margins.png' + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +const VALID_PLANS = new Set(Object.keys(PLAN_PRICES)) +const VALID_INTERVALS = new Set(['month', 'year']) +const VALID_LIMITS = new Set(TRAFFIC_TIERS.map((t) => t.value)) + +function isValidCheckoutParams(plan: string | null, interval: string | null, limit: string | null) { + if (!plan || !interval || !limit) return false + const limitNum = Number(limit) + if (!VALID_PLANS.has(plan)) return false + if (!VALID_INTERVALS.has(interval)) return false + if (!VALID_LIMITS.has(limitNum)) return false + if (!PLAN_PRICES[plan]?.[limitNum]) return false + return true +} + +// --------------------------------------------------------------------------- +// Success polling component (post-3DS return) +// --------------------------------------------------------------------------- + +function CheckoutSuccess() { + const router = useRouter() + const [ready, setReady] = useState(false) + const [timedOut, setTimedOut] = useState(false) + + useEffect(() => { + let cancelled = false + const timeout = setTimeout(() => setTimedOut(true), 30000) + + const poll = async () => { + for (let i = 0; i < 15; i++) { + if (cancelled) return + try { + const data = await getSubscription() + if (data.subscription_status === 'active' || data.subscription_status === 'trialing') { + setReady(true) + clearTimeout(timeout) + setTimeout(() => router.push('/'), 2000) + return + } + } catch { + // ignore — keep polling + } + await new Promise((r) => setTimeout(r, 2000)) + } + setTimedOut(true) + } + poll() + + return () => { + cancelled = true + clearTimeout(timeout) + } + }, [router]) + + return ( +
+ + {ready ? ( + <> +
+ + + +
+

You're all set!

+

Redirecting to dashboard...

+ + ) : timedOut ? ( + <> +

Taking longer than expected

+

+ Your payment was received. It may take a moment to activate. +

+ + Go to dashboard + + + ) : ( + <> +
+

Setting up your subscription...

+

This usually takes a few seconds.

+ + )} + +
+ ) +} + +// --------------------------------------------------------------------------- +// Main checkout content (reads searchParams) +// --------------------------------------------------------------------------- + +function CheckoutContent() { + const router = useRouter() + const searchParams = useSearchParams() + const { user, loading: authLoading } = useAuth() + const { data: subscription } = useSubscription() + + const status = searchParams.get('status') + const plan = searchParams.get('plan') + const interval = searchParams.get('interval') + const limit = searchParams.get('limit') + + // -- Auth guard -- + useEffect(() => { + if (!authLoading && !user) { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search) + router.replace(`/login?redirect=${returnUrl}`) + } + }, [authLoading, user, router]) + + // -- Subscription guard -- + useEffect(() => { + if (subscription && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing')) { + router.replace('/') + } + }, [subscription, router]) + + // -- Param validation -- + useEffect(() => { + if (status === 'success') return // success state doesn't need plan params + if (!authLoading && user && !isValidCheckoutParams(plan, interval, limit)) { + router.replace('/pricing') + } + }, [authLoading, user, plan, interval, limit, status, router]) + + // -- Post-3DS success -- + if (status === 'success') { + return + } + + // -- Loading state -- + if (authLoading || !user || !isValidCheckoutParams(plan, interval, limit)) { + return ( +
+
+
+ ) + } + + const planId = plan! + const billingInterval = interval as 'month' | 'year' + const pageviewLimit = Number(limit) + + return ( +
+ {/* Header */} +
+ + Pulse + +
+ + {/* Main content */} +
+ +
+ {/* Left — Plan summary */} + + + {/* Right — Payment form */} + +
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Page wrapper with Suspense (required for useSearchParams in App Router) +// --------------------------------------------------------------------------- + +export default function CheckoutPage() { + return ( +
+ +
+
+ } + > + +
+
+ ) +}