diff --git a/app/checkout/layout.tsx b/app/checkout/layout.tsx
new file mode 100644
index 0000000..8b9bba6
--- /dev/null
+++ b/app/checkout/layout.tsx
@@ -0,0 +1,8 @@
+export const metadata = {
+ title: 'Checkout — Pulse',
+ robots: 'noindex, nofollow',
+}
+
+export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx
new file mode 100644
index 0000000..55fcb35
--- /dev/null
+++ b/app/checkout/page.tsx
@@ -0,0 +1,252 @@
+'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 FeatureSlideshow from '@/components/checkout/FeatureSlideshow'
+import pulseIcon from '@/public/pulse_icon_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 [country, setCountry] = useState('')
+ const [vatId, setVatId] = useState('')
+
+ 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 (skip on success page — it handles its own redirect) --
+ useEffect(() => {
+ if (status === 'success') return
+ if (subscription && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing')) {
+ router.replace('/')
+ }
+ }, [subscription, status, 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 (
+
+ {/* Left — Feature slideshow (hidden on mobile) */}
+
+
+
+
+ {/* Right — Payment (scrollable) */}
+
+ {/* Logo on mobile only (desktop logo is on the left panel) */}
+
+
+
+ Pulse
+
+
+
+ {/* Main content */}
+
+
+ {/* Plan summary (compact) */}
+
+
+ {/* Payment form */}
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Page wrapper with Suspense (required for useSearchParams in App Router)
+// ---------------------------------------------------------------------------
+
+export default function CheckoutPage() {
+ return (
+
+ }
+ >
+
+
+
+ )
+}
diff --git a/app/integrations/page.tsx b/app/integrations/page.tsx
index 8582f22..dccc121 100644
--- a/app/integrations/page.tsx
+++ b/app/integrations/page.tsx
@@ -188,7 +188,7 @@ export default function IntegrationsPage() {
onClick={() => handleCategoryClick('all')}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === 'all'
- ? 'bg-brand-orange text-white shadow-sm'
+ ? 'bg-brand-orange-button text-white shadow-sm'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
@@ -200,7 +200,7 @@ export default function IntegrationsPage() {
onClick={() => handleCategoryClick(cat)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === cat
- ? 'bg-brand-orange text-white shadow-sm'
+ ? 'bg-brand-orange-button text-white shadow-sm'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
@@ -335,7 +335,7 @@ export default function IntegrationsPage() {
Request Integration
diff --git a/app/layout-content.tsx b/app/layout-content.tsx
index ff50fae..745e0e9 100644
--- a/app/layout-content.tsx
+++ b/app/layout-content.tsx
@@ -78,11 +78,15 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const handleSwitchOrganization = async (orgId: string | null) => {
if (!orgId) return
try {
+ setIsSwitchingOrg(true)
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
- sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
- window.location.reload()
+ // Refresh auth context (re-fetches /auth/user/me with new JWT, updates org_id + SWR cache)
+ await auth.refresh()
+ router.push('/')
+ setTimeout(() => setIsSwitchingOrg(false), 300)
} catch (err) {
+ setIsSwitchingOrg(false)
logger.error('Failed to switch organization', err)
}
}
@@ -91,13 +95,15 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const showOfflineBar = Boolean(auth.user && !isOnline)
// Site pages use DashboardShell with full sidebar — no Header needed
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
+ // Checkout page has its own minimal layout — no app header/footer
+ const isCheckoutPage = pathname.startsWith('/checkout')
if (isSwitchingOrg) {
return
}
- // While auth is loading on a site page, render nothing to prevent flash of public header
- if (auth.loading && isSitePage) {
+ // While auth is loading on a site or checkout page, render nothing to prevent flash of public header
+ if (auth.loading && (isSitePage || isCheckoutPage)) {
return null
}
@@ -113,6 +119,11 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
)
}
+ // Checkout page: render children only (has its own layout)
+ if (isAuthenticated && isCheckoutPage) {
+ return <>{children}>
+ }
+
// Authenticated non-site pages (sites list, onboarding, etc.): static header
if (isAuthenticated) {
return (
diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx
index 25e2e68..7b97e37 100644
--- a/app/sites/[id]/cdn/page.tsx
+++ b/app/sites/[id]/cdn/page.tsx
@@ -187,7 +187,7 @@ export default function CDNPage() {
diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx
index abe8c1d..053f94b 100644
--- a/components/PricingSection.tsx
+++ b/components/PricingSection.tsx
@@ -2,13 +2,12 @@
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
-import { useSearchParams } from 'next/navigation'
+import { useSearchParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import { toast } from '@ciphera-net/ui'
-import { createCheckoutSession } from '@/lib/api/billing'
// 1. Define Plans with IDs and Site Limits
const PLANS = [
@@ -104,12 +103,13 @@ const TRAFFIC_TIERS = [
export default function PricingSection() {
const searchParams = useSearchParams()
+ const router = useRouter()
const [isYearly, setIsYearly] = useState(false)
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
const [loadingPlan, setLoadingPlan] = useState(null)
const { user } = useAuth()
- // * Show toast when redirected from Polar Checkout with canceled=true
+ // * Show toast when redirected from Mollie Checkout with canceled=true
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
toast.info('Checkout was canceled. You can try again whenever you’re ready.')
@@ -166,49 +166,25 @@ export default function PricingSection() {
}
}
- const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
- try {
- setLoadingPlan(planId)
-
- // 1. If not logged in, redirect to login/signup
- if (!user) {
- // Store checkout intent
- const intent = {
- planId,
- interval: isYearly ? 'year' : 'month',
- limit: currentTraffic.value,
- sliderIndex, // Store UI state to restore it
- isYearly // Store UI state to restore it
- }
- localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
-
- initiateOAuthFlow()
- return
+ const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
+ // 1. If not logged in, redirect to login/signup
+ if (!user) {
+ const intent = {
+ planId,
+ interval: isYearly ? 'year' : 'month',
+ limit: currentTraffic.value,
+ sliderIndex,
+ isYearly
}
-
- // 2. Call backend to create checkout session
- const interval = options?.interval || (isYearly ? 'year' : 'month')
- const limit = options?.limit || currentTraffic.value
-
- const { url } = await createCheckoutSession({
- plan_id: planId,
- interval,
- limit,
- })
-
- // 3. Redirect to Polar Checkout
- if (url) {
- window.location.href = url
- } else {
- throw new Error('No checkout URL returned')
- }
-
- } catch (error: unknown) {
- logger.error('Checkout error:', error)
- toast.error('Failed to start checkout — please try again')
- } finally {
- setLoadingPlan(null)
+ localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
+ initiateOAuthFlow()
+ return
}
+
+ // 2. Navigate to embedded checkout page
+ const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
+ const selectedLimit = options?.limit || currentTraffic.value
+ router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
}
return (
@@ -254,7 +230,7 @@ export default function PricingSection() {
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
- className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
+ className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
/>
@@ -355,6 +331,7 @@ export default function PricingSection() {
€{priceDetails.yearlyTotal}
/year
+ excl. VAT
@@ -371,6 +348,7 @@ export default function PricingSection() {
€{priceDetails.baseMonthly}
/mo
+ excl. VAT
)
) : (
@@ -437,6 +415,7 @@ export default function PricingSection() {
+
)
}
diff --git a/components/checkout/FeatureSlideshow.tsx b/components/checkout/FeatureSlideshow.tsx
new file mode 100644
index 0000000..d172b82
--- /dev/null
+++ b/components/checkout/FeatureSlideshow.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import Image from 'next/image'
+import Link from 'next/link'
+import pulseIcon from '@/public/pulse_icon_no_margins.png'
+import { AnimatePresence, motion } from 'framer-motion'
+import { PulseMockup } from '@/components/marketing/mockups/pulse-mockup'
+import { PagesCard, ReferrersCard, LocationsCard, TechnologyCard, PeakHoursCard } from '@/components/marketing/mockups/pulse-features-carousel'
+
+interface Slide {
+ headline: string
+ mockup: React.ReactNode
+}
+
+function FeatureCard({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+const slides: Slide[] = [
+ { headline: 'Your traffic, at a glance.', mockup: },
+ { headline: 'See which pages perform best.', mockup: },
+ { headline: 'Know where your visitors come from.', mockup: },
+ { headline: 'Visitors from around the world.', mockup: },
+ { headline: 'Understand your audience\u2019s tech stack.', mockup: },
+ { headline: 'Find your peak traffic hours.', mockup: },
+]
+
+export default function FeatureSlideshow() {
+ const [activeIndex, setActiveIndex] = useState(0)
+
+ const advance = useCallback(() => {
+ setActiveIndex((prev) => (prev + 1) % slides.length)
+ }, [])
+
+ useEffect(() => {
+ let timer: ReturnType | null = null
+
+ const start = () => { timer = setInterval(advance, 8000) }
+ const stop = () => { if (timer) { clearInterval(timer); timer = null } }
+
+ const onVisibility = () => {
+ if (document.hidden) stop()
+ else start()
+ }
+
+ start()
+ document.addEventListener('visibilitychange', onVisibility)
+ return () => {
+ stop()
+ document.removeEventListener('visibilitychange', onVisibility)
+ }
+ }, [advance])
+
+ const slide = slides[activeIndex]
+
+ return (
+
+ {/* Background image */}
+
+
+ {/* Dark overlay */}
+
+
+ {/* Logo */}
+
+
+
+ Pulse
+
+
+
+ {/* Content */}
+
+
+
+ {/* Headline — centered */}
+
+ {slide.headline}
+
+
+ {/* Mockup — constrained */}
+
+ {/* Orange glow */}
+
+
+
+ {slide.mockup}
+
+
+
+
+
+
+ )
+}
diff --git a/components/checkout/PaymentForm.tsx b/components/checkout/PaymentForm.tsx
new file mode 100644
index 0000000..069d341
--- /dev/null
+++ b/components/checkout/PaymentForm.tsx
@@ -0,0 +1,355 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import Script from 'next/script'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Lock, ShieldCheck } from '@phosphor-icons/react'
+import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie'
+import { createEmbeddedCheckout, createCheckoutSession } from '@/lib/api/billing'
+
+interface PaymentFormProps {
+ plan: string
+ interval: string
+ limit: number
+ country: string
+ vatId: string
+}
+
+const PAYMENT_METHODS = [
+ { id: 'card', label: 'Card' },
+ { id: 'bancontact', label: 'Bancontact' },
+ { id: 'ideal', label: 'iDEAL' },
+ { id: 'applepay', label: 'Apple Pay' },
+ { id: 'googlepay', label: 'Google Pay' },
+ { id: 'directdebit', label: 'SEPA' },
+]
+
+const METHOD_LOGOS: Record = {
+ card: { src: ['/images/payment/visa.svg', '/images/payment/mastercard.svg'], alt: 'Card' },
+ bancontact: { src: '/images/payment/bancontact.svg', alt: 'Bancontact' },
+ ideal: { src: '/images/payment/ideal.svg', alt: 'iDEAL' },
+ applepay: { src: '/images/payment/applepay.svg', alt: 'Apple Pay' },
+ googlepay: { src: '/images/payment/googlepay.svg', alt: 'Google Pay' },
+ directdebit: { src: '/images/payment/sepa.svg', alt: 'SEPA' },
+}
+
+function MethodLogo({ type }: { type: string }) {
+ const logo = METHOD_LOGOS[type]
+ if (!logo) return null
+
+ if (Array.isArray(logo.src)) {
+ return (
+
+ {logo.src.map((s) => (
+

+ ))}
+
+ )
+ }
+
+ return
+}
+
+const mollieFieldBase =
+ 'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-3 h-[48px] transition-all focus-within:ring-1 focus-within:ring-brand-orange focus-within:border-brand-orange'
+
+export default function PaymentForm({ plan, interval, limit, country, vatId }: PaymentFormProps) {
+ const router = useRouter()
+
+ const [selectedMethod, setSelectedMethod] = useState('')
+ const [mollieReady, setMollieReady] = useState(false)
+ const [mollieError, setMollieError] = useState(false)
+ const [formError, setFormError] = useState(null)
+ const [cardErrors, setCardErrors] = useState>({})
+ const [submitted, setSubmitted] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+
+ const submitRef = useRef(null)
+ const componentsRef = useRef>({
+ cardHolder: null,
+ cardNumber: null,
+ expiryDate: null,
+ verificationCode: null,
+ })
+ const mollieInitialized = useRef(false)
+
+ const [scriptLoaded, setScriptLoaded] = useState(false)
+
+ // Mount Mollie components AFTER script loaded
+ useEffect(() => {
+ if (!scriptLoaded || mollieInitialized.current) return
+
+ const timer = setTimeout(() => {
+ const mollie = initMollie()
+ if (!mollie) {
+ setMollieError(true)
+ return
+ }
+
+ try {
+ const fields: Array<{ type: string; selector: string; placeholder?: string }> = [
+ { type: 'cardHolder', selector: '#mollie-card-holder', placeholder: 'John Doe' },
+ { type: 'cardNumber', selector: '#mollie-card-number', placeholder: '1234 5678 9012 3456' },
+ { type: 'expiryDate', selector: '#mollie-card-expiry', placeholder: 'MM / YY' },
+ { type: 'verificationCode', selector: '#mollie-card-cvc', placeholder: 'CVC' },
+ ]
+
+ for (const { type, selector, placeholder } of fields) {
+ const el = document.querySelector(selector) as HTMLElement | null
+ if (!el) {
+ setMollieError(true)
+ return
+ }
+ const opts: Record = { styles: MOLLIE_FIELD_STYLES }
+ if (placeholder) opts.placeholder = placeholder
+ const component = mollie.createComponent(type, opts)
+ component.mount(el)
+ component.addEventListener('change', (event: unknown) => {
+ const e = event as { error?: string }
+ setCardErrors((prev) => {
+ const next = { ...prev }
+ if (e.error) next[type] = e.error
+ else delete next[type]
+ return next
+ })
+ })
+ componentsRef.current[type] = component
+ }
+
+ mollieInitialized.current = true
+ setMollieReady(true)
+ } catch (err) {
+ console.error('Mollie mount error:', err)
+ setMollieError(true)
+ }
+ }, 100)
+
+ return () => clearTimeout(timer)
+ }, [scriptLoaded])
+
+ // Cleanup Mollie components on unmount
+ useEffect(() => {
+ return () => {
+ Object.values(componentsRef.current).forEach((c) => {
+ try { c?.unmount() } catch { /* DOM already removed */ }
+ })
+ }
+ }, [])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSubmitted(true)
+ setFormError(null)
+
+ if (!selectedMethod) {
+ setFormError('Please select a payment method')
+ return
+ }
+
+ if (!country) {
+ setFormError('Please select your country')
+ return
+ }
+
+ setSubmitting(true)
+
+ try {
+ if (selectedMethod === 'card') {
+ const mollie = getMollie()
+ if (!mollie) {
+ setFormError('Payment system not loaded. Please refresh.')
+ setSubmitting(false)
+ return
+ }
+
+ const { token, error } = await mollie.createToken()
+ if (error || !token) {
+ setFormError(error?.message || 'Invalid card details.')
+ setSubmitting(false)
+ return
+ }
+
+ const result = await createEmbeddedCheckout({
+ plan_id: plan,
+ interval,
+ limit,
+ country,
+ vat_id: vatId || undefined,
+ card_token: token,
+ })
+
+ if (result.status === 'success') router.push('/checkout?status=success')
+ else if (result.status === 'pending' && result.redirect_url)
+ window.location.href = result.redirect_url
+ } else {
+ const result = await createCheckoutSession({
+ plan_id: plan,
+ interval,
+ limit,
+ country,
+ vat_id: vatId || undefined,
+ method: selectedMethod,
+ })
+ window.location.href = result.url
+ }
+ } catch (err) {
+ setFormError((err as Error)?.message || 'Payment failed. Please try again.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const isCard = selectedMethod === 'card'
+
+ return (
+ <>
+