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 + 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() {

@@ -583,7 +557,6 @@ function WelcomeContent() { variant="secondary" className="w-full sm:w-auto" onClick={handlePlanSkip} - disabled={planLoading} > Stay on free plan 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 + 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 {logo.alt} +} + +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 ( + <> +