From 704557f233f7a6c434c2aec38c59f75175b2835a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 20:24:54 +0100 Subject: [PATCH 01/73] feat: update frontend billing api for mollie (country, vat_id, payment method update) --- components/PricingSection.tsx | 4 ++-- lib/api/billing.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index abe8c1d..3d6c276 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -109,7 +109,7 @@ export default function PricingSection() { 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.') @@ -196,7 +196,7 @@ export default function PricingSection() { limit, }) - // 3. Redirect to Polar Checkout + // 3. Redirect to Mollie Checkout if (url) { window.location.href = url } else { diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 49660a6..9f2d29c 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -72,6 +72,8 @@ export interface CreateCheckoutParams { plan_id: string interval: string limit: number + country: string + vat_id?: string } export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> { @@ -81,6 +83,13 @@ export async function createCheckoutSession(params: CreateCheckoutParams): Promi }) } +/** Creates a Mollie checkout session to update the payment mandate. */ +export async function updatePaymentMethod(): Promise<{ url: string }> { + return apiRequest<{ url: string }>('/api/billing/update-payment-method', { + method: 'POST', + }) +} + export interface Order { id: string total_amount: number -- 2.49.1 From 94d0b3498fd16c8b300a8264b9fc39072746a134 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 20:27:07 +0100 Subject: [PATCH 02/73] feat: add country and vat id fields to checkout flow --- app/welcome/page.tsx | 63 +++++++++++- components/PricingSection.tsx | 185 +++++++++++++++++++++++++++++----- 2 files changed, 221 insertions(+), 27 deletions(-) diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index 55065f6..e30ee61 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -102,6 +102,10 @@ function WelcomeContent() { const [hadPendingCheckout, setHadPendingCheckout] = useState(null) const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false) + const [welcomeCountry, setWelcomeCountry] = useState('') + const [welcomeVatId, setWelcomeVatId] = useState('') + const [showBillingFields, setShowBillingFields] = useState(false) + const [organizations, setOrganizations] = useState(null) const [orgsLoading, setOrgsLoading] = useState(false) const [switchingOrgId, setSwitchingOrgId] = useState(null) @@ -211,6 +215,13 @@ function WelcomeContent() { setStep(4) return } + + // Show billing fields first if country not yet selected + if (!welcomeCountry) { + setShowBillingFields(true) + return + } + setPlanLoading(true) setPlanError('') try { @@ -220,6 +231,8 @@ function WelcomeContent() { plan_id: intent.planId, interval: intent.interval || 'month', limit: intent.limit ?? 100000, + country: welcomeCountry, + vat_id: welcomeVatId || undefined, }) localStorage.removeItem('pulse_pending_checkout') if (url) { @@ -568,6 +581,52 @@ function WelcomeContent() { {planError && (

{planError}

)} + {showBillingFields && showPendingCheckoutInStep3 && ( +
+
+ + +
+
+ + ) => setWelcomeVatId(e.target.value)} + placeholder="e.g. BE0123456789" + /> +
+
+ )}
{showPendingCheckoutInStep3 ? ( <> @@ -575,9 +634,9 @@ function WelcomeContent() { variant="primary" className="w-full sm:w-auto" onClick={handlePlanContinue} - disabled={planLoading} + disabled={planLoading || (showBillingFields && !welcomeCountry)} > - Continue to checkout + {showBillingFields ? 'Continue to payment' : 'Continue to checkout'}
+ + {/* Checkout Country / VAT Modal */} + {showCheckoutForm && ( +
+
+ +

+ Billing details +

+

+ Select your country to calculate the correct tax rate. +

+ +
+ {/* Country */} +
+ + +
+ + {/* VAT ID */} +
+ + setCheckoutVatId(e.target.value)} + placeholder="e.g. BE0123456789" + className="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-brand-orange focus:outline-none focus:ring-1 focus:ring-brand-orange transition-colors" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ )} ) } -- 2.49.1 From 4e7ad887636d432cb74f9aa68ea807a6b93dfa22 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 20:46:47 +0100 Subject: [PATCH 03/73] fix: update billing tab for mollie response format, use updatePaymentMethod --- .../unified/tabs/WorkspaceBillingTab.tsx | 23 ++++++++----------- lib/api/billing.ts | 6 +---- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/components/settings/unified/tabs/WorkspaceBillingTab.tsx b/components/settings/unified/tabs/WorkspaceBillingTab.tsx index e61c7cc..a5ecdff 100644 --- a/components/settings/unified/tabs/WorkspaceBillingTab.tsx +++ b/components/settings/unified/tabs/WorkspaceBillingTab.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { Button, toast, Spinner } from '@ciphera-net/ui' import { CreditCard, ArrowSquareOut } from '@phosphor-icons/react' import { useSubscription } from '@/lib/swr/dashboard' -import { createPortalSession, cancelSubscription, resumeSubscription, getOrders, type Order } from '@/lib/api/billing' +import { updatePaymentMethod, cancelSubscription, resumeSubscription, getOrders, type Order } from '@/lib/api/billing' import { formatDateLong, formatDate } from '@/lib/utils/formatDate' import { getAuthErrorMessage } from '@ciphera-net/ui' @@ -18,16 +18,16 @@ export default function WorkspaceBillingTab() { getOrders().then(setOrders).catch(() => {}) }, []) - const formatAmount = (amount: number, currency: string) => { - return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'USD' }).format(amount / 100) + const formatAmount = (amount: string, currency: string) => { + return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'EUR' }).format(parseFloat(amount)) } const handleManageBilling = async () => { try { - const { url } = await createPortalSession() - if (url) window.open(url, '_blank') + const { url } = await updatePaymentMethod() + if (url) window.location.href = url } catch (err) { - toast.error(getAuthErrorMessage(err as Error) || 'Failed to open billing portal') + toast.error(getAuthErrorMessage(err as Error) || 'Failed to update payment method') } } @@ -149,7 +149,7 @@ export default function WorkspaceBillingTab() { {subscription.has_payment_method && ( )} @@ -180,13 +180,10 @@ export default function WorkspaceBillingTab() {
{formatDate(new Date(order.created_at))} - {formatAmount(order.total_amount, order.currency)} - {order.invoice_number && ( - {order.invoice_number} - )} + {formatAmount(order.amount, order.currency)}
- - {order.paid ? 'Paid' : order.status} + + {order.status === 'paid' ? 'Paid' : order.status}
))} diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 9f2d29c..666ac47 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -92,14 +92,10 @@ export async function updatePaymentMethod(): Promise<{ url: string }> { export interface Order { id: string - total_amount: number - subtotal_amount: number - tax_amount: number + amount: string currency: string status: string created_at: string - paid: boolean - invoice_number: string } export async function getOrders(): Promise { -- 2.49.1 From d419322ab71e39cf8de91b9e8d45d0c49e6966a7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:22:12 +0100 Subject: [PATCH 04/73] refactor: extract shared country list and plan prices --- components/PricingSection.tsx | 39 ++++------------------------------- lib/countries.ts | 35 +++++++++++++++++++++++++++++++ lib/plans.ts | 11 ++++++++++ 3 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 lib/countries.ts diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index e7c4af4..656b036 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -8,6 +8,7 @@ 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 { COUNTRY_OPTIONS } from '@/lib/countries' import { createCheckoutSession } from '@/lib/api/billing' // 1. Define Plans with IDs and Site Limits @@ -102,40 +103,8 @@ const TRAFFIC_TIERS = [ }, ] -const COUNTRY_OPTIONS = [ - { code: 'BE', label: 'Belgium' }, - { code: 'NL', label: 'Netherlands' }, - { code: 'DE', label: 'Germany' }, - { code: 'FR', label: 'France' }, - { code: 'AT', label: 'Austria' }, - { code: 'IT', label: 'Italy' }, - { code: 'ES', label: 'Spain' }, - { code: 'PT', label: 'Portugal' }, - { code: 'IE', label: 'Ireland' }, - { code: 'LU', label: 'Luxembourg' }, - { code: 'FI', label: 'Finland' }, - { code: 'SE', label: 'Sweden' }, - { code: 'DK', label: 'Denmark' }, - { code: 'PL', label: 'Poland' }, - { code: 'CZ', label: 'Czech Republic' }, - { code: 'RO', label: 'Romania' }, - { code: 'BG', label: 'Bulgaria' }, - { code: 'HR', label: 'Croatia' }, - { code: 'SI', label: 'Slovenia' }, - { code: 'SK', label: 'Slovakia' }, - { code: 'HU', label: 'Hungary' }, - { code: 'LT', label: 'Lithuania' }, - { code: 'LV', label: 'Latvia' }, - { code: 'EE', label: 'Estonia' }, - { code: 'MT', label: 'Malta' }, - { code: 'CY', label: 'Cyprus' }, - { code: 'GR', label: 'Greece' }, - { code: 'US', label: 'United States' }, - { code: 'GB', label: 'United Kingdom' }, - { code: 'CH', label: 'Switzerland' }, - { code: 'NO', label: 'Norway' }, - { code: 'CA', label: 'Canada' }, - { code: 'AU', label: 'Australia' }, +const PRICING_COUNTRY_OPTIONS = [ + ...COUNTRY_OPTIONS.map((c) => ({ code: c.value, label: c.label })), { code: 'OTHER', label: 'Other' }, ] @@ -528,7 +497,7 @@ export default function PricingSection() { className="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-brand-orange focus:outline-none focus:ring-1 focus:ring-brand-orange transition-colors" > - {COUNTRY_OPTIONS.map((c) => ( + {PRICING_COUNTRY_OPTIONS.map((c) => ( ))} diff --git a/lib/countries.ts b/lib/countries.ts new file mode 100644 index 0000000..33959e1 --- /dev/null +++ b/lib/countries.ts @@ -0,0 +1,35 @@ +export const COUNTRY_OPTIONS = [ + { value: 'BE', label: 'Belgium' }, + { value: 'NL', label: 'Netherlands' }, + { value: 'DE', label: 'Germany' }, + { value: 'FR', label: 'France' }, + { value: 'AT', label: 'Austria' }, + { value: 'IT', label: 'Italy' }, + { value: 'ES', label: 'Spain' }, + { value: 'PT', label: 'Portugal' }, + { value: 'IE', label: 'Ireland' }, + { value: 'LU', label: 'Luxembourg' }, + { value: 'FI', label: 'Finland' }, + { value: 'SE', label: 'Sweden' }, + { value: 'DK', label: 'Denmark' }, + { value: 'PL', label: 'Poland' }, + { value: 'CZ', label: 'Czech Republic' }, + { value: 'RO', label: 'Romania' }, + { value: 'BG', label: 'Bulgaria' }, + { value: 'HR', label: 'Croatia' }, + { value: 'SI', label: 'Slovenia' }, + { value: 'SK', label: 'Slovakia' }, + { value: 'HU', label: 'Hungary' }, + { value: 'LT', label: 'Lithuania' }, + { value: 'LV', label: 'Latvia' }, + { value: 'EE', label: 'Estonia' }, + { value: 'MT', label: 'Malta' }, + { value: 'CY', label: 'Cyprus' }, + { value: 'GR', label: 'Greece' }, + { value: 'US', label: 'United States' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'CH', label: 'Switzerland' }, + { value: 'NO', label: 'Norway' }, + { value: 'CA', label: 'Canada' }, + { value: 'AU', label: 'Australia' }, +] as const diff --git a/lib/plans.ts b/lib/plans.ts index a2d2ce9..0707111 100644 --- a/lib/plans.ts +++ b/lib/plans.ts @@ -79,3 +79,14 @@ export function formatRetentionMonths(months: number): string { if (Number.isInteger(years)) return years === 1 ? '1 year' : `${years} years` return `${months} months` } + +/** + * Monthly prices in EUR cents per plan and traffic tier. + * IMPORTANT: Must stay in sync with backend GetSubscriptionAmount() in internal/billing/mollie.go + * Yearly = monthly * 11 (1 month free) + */ +export const PLAN_PRICES: Record> = { + solo: { 10000: 700, 50000: 1100, 100000: 1500, 250000: 2500, 500000: 3900, 1000000: 5500, 2500000: 7900, 5000000: 10300, 10000000: 13500 }, + team: { 10000: 1100, 50000: 1900, 100000: 2300, 250000: 3900, 500000: 5900, 1000000: 7900, 2500000: 11900, 5000000: 15500, 10000000: 19900 }, + business: { 10000: 1500, 50000: 2700, 100000: 3100, 250000: 5900, 500000: 7900, 1000000: 11100, 2500000: 16900, 5000000: 20700, 10000000: 26900 }, +} -- 2.49.1 From 6ea520e0eddcc3689e16c5b3c9b056e312ca7fa9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:24:27 +0100 Subject: [PATCH 05/73] feat: add mollie.js helper and embedded checkout API call --- lib/api/billing.ts | 16 +++++++++++ lib/mollie.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 lib/mollie.ts diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 666ac47..22c1391 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -101,3 +101,19 @@ export interface Order { export async function getOrders(): Promise { return apiRequest('/api/billing/invoices') } + +export interface CreateEmbeddedCheckoutParams { + plan_id: string + interval: string + limit: number + country: string + vat_id?: string + card_token: string +} + +export async function createEmbeddedCheckout(params: CreateEmbeddedCheckoutParams): Promise<{ status: 'success' | 'pending'; redirect_url?: string }> { + return apiRequest<{ status: 'success' | 'pending'; redirect_url?: string }>('/api/billing/checkout-embedded', { + method: 'POST', + body: JSON.stringify(params), + }) +} diff --git a/lib/mollie.ts b/lib/mollie.ts new file mode 100644 index 0000000..e731c8c --- /dev/null +++ b/lib/mollie.ts @@ -0,0 +1,69 @@ +'use client' + +// Mollie.js types (no official @types package) +export interface MollieInstance { + createComponent: (type: string, options?: { styles?: Record }) => MollieComponent + createToken: () => Promise<{ token: string | null; error: MollieError | null }> +} + +export interface MollieComponent { + mount: (selector: string | HTMLElement) => void + unmount: () => void + addEventListener: (event: string, callback: (event: unknown) => void) => void +} + +export interface MollieError { + field: string + message: string +} + +declare global { + interface Window { + Mollie: (profileId: string, options?: { locale?: string; testmode?: boolean }) => MollieInstance + } +} + +const MOLLIE_PROFILE_ID = process.env.NEXT_PUBLIC_MOLLIE_PROFILE_ID || '' + +// Mollie Components card field styles — matches Pulse dark theme +export const MOLLIE_FIELD_STYLES = { + base: { + color: '#ffffff', + fontSize: '14px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontWeight: '400', + letterSpacing: '0.025em', + '::placeholder': { + color: '#737373', + }, + }, + valid: { + color: '#ffffff', + }, + invalid: { + color: '#ef4444', + }, +} + +let mollieInstance: MollieInstance | null = null + +/** + * Initialize Mollie.js. Must be called after the Mollie script has loaded. + */ +export function initMollie(): MollieInstance | null { + if (mollieInstance) return mollieInstance + if (typeof window === 'undefined' || !window.Mollie || !MOLLIE_PROFILE_ID) return null + + // Mollie auto-detects test/live mode based on the API key configured server-side. + mollieInstance = window.Mollie(MOLLIE_PROFILE_ID, { + locale: 'en_US', + }) + return mollieInstance +} + +/** + * Get the current Mollie instance (must call initMollie first). + */ +export function getMollie(): MollieInstance | null { + return mollieInstance +} -- 2.49.1 From 837f4401070d754bfc7a4c2eb239e6e7e245bf5d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:25:51 +0100 Subject: [PATCH 06/73] feat: add plan summary component for checkout page --- components/checkout/PlanSummary.tsx | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 components/checkout/PlanSummary.tsx diff --git a/components/checkout/PlanSummary.tsx b/components/checkout/PlanSummary.tsx new file mode 100644 index 0000000..7a6eab7 --- /dev/null +++ b/components/checkout/PlanSummary.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Check } from '@phosphor-icons/react' +import { + TRAFFIC_TIERS, + getSitesLimitForPlan, + getMaxRetentionMonthsForPlan, + PLAN_PRICES, +} from '@/lib/plans' + +interface PlanSummaryProps { + plan: string + interval: string + limit: number +} + +export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps) { + const router = useRouter() + const searchParams = useSearchParams() + const [currentInterval, setCurrentInterval] = useState(interval) + + const monthlyCents = PLAN_PRICES[plan]?.[limit] || 0 + const isYearly = currentInterval === 'year' + const displayPrice = isYearly ? (monthlyCents * 11) / 100 : monthlyCents / 100 + const monthlyEquivalent = isYearly ? displayPrice / 12 : displayPrice + + const tierLabel = + TRAFFIC_TIERS.find((t) => t.value === limit)?.label || + `${(limit / 1000).toFixed(0)}k` + const sitesLimit = getSitesLimitForPlan(plan) + const retentionMonths = getMaxRetentionMonthsForPlan(plan) + const retentionLabel = + retentionMonths >= 12 + ? `${retentionMonths / 12} year${retentionMonths > 12 ? 's' : ''}` + : `${retentionMonths} months` + + const handleIntervalToggle = (newInterval: string) => { + setCurrentInterval(newInterval) + const params = new URLSearchParams(searchParams.toString()) + params.set('interval', newInterval) + router.replace(`/checkout?${params.toString()}`, { scroll: false }) + } + + const features = [ + `${tierLabel} pageviews/mo`, + `${sitesLimit} site${sitesLimit && sitesLimit > 1 ? 's' : ''}`, + `${retentionLabel} data retention`, + 'Unlimited team members', + 'Custom events & goals', + 'Funnels & user journeys', + ] + + return ( +
+ {/* Plan header */} +
+

{plan}

+ + 30-day trial + +
+ + {/* Price display */} +
+
+ + €{isYearly ? monthlyEquivalent.toFixed(2) : displayPrice.toFixed(0)} + + /mo +
+ {isYearly && ( +
+ + €{displayPrice.toFixed(2)} billed yearly + + + Save 1 month + +
+ )} +
+ + {/* Interval toggle */} +
+ + +
+ + {/* Divider */} +
+ + {/* Features list */} +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ ) +} -- 2.49.1 From 89575c9fcbb7f96febb488c660fa17290b38e85c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:26:32 +0100 Subject: [PATCH 07/73] 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 ( +
+ +
+
+ } + > + +
+
+ ) +} -- 2.49.1 From e23ec2ca403115f688f73010a03d307410564d2d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Mar 2026 21:26:38 +0100 Subject: [PATCH 08/73] feat: add payment form with mollie components card fields --- components/checkout/PaymentForm.tsx | 256 ++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 components/checkout/PaymentForm.tsx diff --git a/components/checkout/PaymentForm.tsx b/components/checkout/PaymentForm.tsx new file mode 100644 index 0000000..a9f5304 --- /dev/null +++ b/components/checkout/PaymentForm.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Script from 'next/script' +import { Lock, ShieldCheck } from '@phosphor-icons/react' +import { COUNTRY_OPTIONS } from '@/lib/countries' +import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie' +import { createEmbeddedCheckout } from '@/lib/api/billing' + +interface PaymentFormProps { + plan: string + interval: string + limit: number +} + +const inputClass = + 'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange focus:border-brand-orange transition-colors' +const mollieFieldClass = + 'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-2.5 h-[42px] transition-colors focus-within:ring-1 focus-within:ring-brand-orange focus-within:border-brand-orange' + +export default function PaymentForm({ plan, interval, limit }: PaymentFormProps) { + const router = useRouter() + const searchParams = useSearchParams() + const currentInterval = searchParams.get('interval') || interval + + const [country, setCountry] = useState('') + const [vatId, setVatId] = useState('') + const [mollieReady, setMollieReady] = useState(false) + const [mollieError, setMollieError] = useState(false) + const [formError, setFormError] = useState(null) + const [cardErrors, setCardErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + const componentsRef = useRef>({ + cardNumber: null, + cardExpiry: null, + cardCvc: null, + }) + + const mountMollieComponents = () => { + const mollie = initMollie() + if (!mollie) { + setMollieError(true) + return + } + + try { + const fields: Array<{ type: string; selector: string }> = [ + { type: 'cardNumber', selector: '#mollie-card-number' }, + { type: 'cardExpiry', selector: '#mollie-card-expiry' }, + { type: 'cardCvc', selector: '#mollie-card-cvc' }, + ] + + for (const { type, selector } of fields) { + const component = mollie.createComponent(type, { styles: MOLLIE_FIELD_STYLES }) + component.mount(selector) + component.addEventListener('change', (event: unknown) => { + const e = event as { error?: string; touched?: boolean } + setCardErrors((prev) => { + const next = { ...prev } + if (e.error) next[type] = e.error + else delete next[type] + return next + }) + }) + componentsRef.current[type] = component + } + + setMollieReady(true) + } catch { + setMollieError(true) + } + } + + // Cleanup Mollie components on unmount + useEffect(() => { + return () => { + Object.values(componentsRef.current).forEach((c) => c?.unmount()) + } + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setFormError(null) + if (!country) { + setFormError('Please select your country') + return + } + + const mollie = getMollie() + if (!mollie) { + setFormError('Payment system not loaded. Please refresh.') + return + } + + setSubmitting(true) + try { + 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: currentInterval, + 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 + } catch (err) { + setFormError((err as Error)?.message || 'Payment failed. Please try again.') + } finally { + setSubmitting(false) + } + } + + return ( + <> +