import { useState, useEffect, useRef, useCallback } from 'react'; import { CheckIcon } from '../ui/icons/CheckIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { AuthUser } from '../../hooks/useAuth'; import { useLicense } from '../../hooks/useLicense'; import { trackEvent } from '../../lib/analytics'; import { apiUrl } from '../../lib/api'; const FEATURES = [ '56 data layers across England', 'Every postcode scored and filterable', 'Unlimited map exploration and exports', 'Multiple decades of historical price data', 'Crime, schools, transport, broadband & more', 'All future data updates included', ]; interface PricingTier { up_to: number | null; price_pence: number; slots: number; } interface PricingData { licensed_count: number; current_price_pence: number; tiers: PricingTier[]; } function formatPrice(pence: number): string { if (pence === 0) return 'Free'; return `\u00A3${pence / 100}`; } function tierLabel(tier: PricingTier, index: number): string { if (index === 0) return `First ${tier.slots} users`; if (tier.up_to === null) return 'Everyone after'; return `Next ${tier.slots} users`; } export default function PricingPage({ onOpenDashboard, user, onLoginClick, onRegisterClick, }: { onOpenDashboard: () => void; user?: AuthUser | null; onLoginClick?: () => void; onRegisterClick?: () => void; }) { const license = useLicense(); const [pricing, setPricing] = useState(null); const [loading, setLoading] = useState(true); const [scrolledLeft, setScrolledLeft] = useState(false); const scrollRef = useRef(null); const activeCardRef = useRef(null); const onScroll = useCallback(() => { if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0); }, []); useEffect(() => { trackEvent('Pricing View'); }, []); useEffect(() => { fetch(apiUrl('pricing')) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then(setPricing) .catch((err) => console.error('Failed to load pricing:', err)) .finally(() => setLoading(false)); }, []); const isLicensed = user?.subscription === 'licensed' || user?.isAdmin; const currentPrice = pricing?.current_price_pence ?? 10000; const isFree = currentPrice === 0; // Find current tier index and remaining spots let currentTierIndex = (pricing?.tiers.length ?? 1) - 1; let spotsRemaining = 0; if (pricing) { for (let i = 0; i < pricing.tiers.length; i++) { const tier = pricing.tiers[i]; if (tier.up_to === null || pricing.licensed_count < tier.up_to) { currentTierIndex = i; spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count; break; } } } useEffect(() => { if (!pricing || !scrollRef.current || !activeCardRef.current) return; if (currentTierIndex === 0) return; const container = scrollRef.current; const card = activeCardRef.current; const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; container.scrollLeft = Math.max(0, scrollLeft); setScrolledLeft(container.scrollLeft > 0); }, [pricing, currentTierIndex]); const ctaButton = isLicensed ? ( ) : user ? ( ) : ( ); return (
{/* Aurora — sized divs with oklch gradients, no blur */}
{/* Green curtain — top left */}
{/* Teal sweep — center */}
{/* Purple curtain — right */}
{/* Deep violet — bottom right */}
{/* Emerald — bottom left */}
{/* Cyan accent — upper center */}

Early access pricing

Pay once, access forever. The earlier you join, the less you pay.

Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.

Less than your survey costs. Vastly more useful.

{/* Tier cards — full viewport width carousel */} {loading ? (
) : pricing ? (
{scrolledLeft &&
}
{pricing.tiers.map((tier, i) => { const isCurrent = i === currentTierIndex; const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to; const filledInTier = isCurrent ? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) : 0; const tierSlots = tier.slots; const fillPercent = isFilled ? 100 : isCurrent && tierSlots > 0 ? (filledInTier / tierSlots) * 100 : 0; return (
{isCurrent && (
Current tier
)}

{tierLabel(tier, i)}

{formatPrice(tier.price_pence)} {tier.price_pence > 0 && ( /lifetime )}
{isCurrent && spotsRemaining > 0 && (

{spotsRemaining} spot {spotsRemaining !== 1 ? 's' : ''} remaining

)} {isFilled && (

Filled

)}
{/* Progress bar for current tier */} {isCurrent && tierSlots > 0 && (
)}
    {FEATURES.map((feature) => (
  • {feature}
  • ))}
{isCurrent ? ( <> {ctaButton} {license.error && (

{license.error}

)}

{isFree ? 'No credit card required' : '30-day money-back guarantee'}

) : isFilled ? (
Sold out
) : (
Upcoming
)}
); })}
) : (

Failed to load pricing. Please try again later.

)}
); }