changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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';
|
||||
|
|
@ -9,7 +9,7 @@ const FEATURES = [
|
|||
'56 data layers across England',
|
||||
'Every postcode scored and filterable',
|
||||
'Unlimited map exploration and exports',
|
||||
'Historical price data back to 1995',
|
||||
'Multiple decades of historical price data',
|
||||
'Crime, schools, transport, broadband & more',
|
||||
'All future data updates included',
|
||||
];
|
||||
|
|
@ -50,6 +50,23 @@ export default function PricingPage({
|
|||
}) {
|
||||
const license = useLicense();
|
||||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scrolledLeft, setScrolledLeft] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const onScroll = useCallback(() => {
|
||||
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
|
|
@ -58,7 +75,8 @@ export default function PricingPage({
|
|||
return res.json();
|
||||
})
|
||||
.then(setPricing)
|
||||
.catch((err) => console.error('Failed to load pricing:', err));
|
||||
.catch((err) => console.error('Failed to load pricing:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
||||
|
|
@ -80,170 +98,254 @@ export default function PricingPage({
|
|||
}
|
||||
}
|
||||
|
||||
const ctaButton = isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
Open dashboard
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
)}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free access'
|
||||
: `Get started — ${formatPrice(currentPrice)}`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? 'Claim free access' : 'Get started'}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
||||
{/* Aurora — sized divs with oklch gradients, no blur */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
{/* Green curtain — top left */}
|
||||
<div
|
||||
className="absolute w-[90vw] h-[80vh] -top-[10%] -left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 20s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Teal sweep — center */}
|
||||
<div
|
||||
className="absolute w-[80vw] h-[70vh] top-[5%] left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-2 18s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Purple curtain — right */}
|
||||
<div
|
||||
className="absolute w-[85vw] h-[90vh] -top-[5%] -right-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-4 25s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Deep violet — bottom right */}
|
||||
<div
|
||||
className="absolute w-[75vw] h-[70vh] -bottom-[5%] right-[5%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-3 22s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Emerald — bottom left */}
|
||||
<div
|
||||
className="absolute w-[80vw] h-[75vh] -bottom-[10%] -left-[10%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-5 24s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Cyan accent — upper center */}
|
||||
<div
|
||||
className="absolute w-[70vw] h-[60vh] top-[20%] left-[20%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 16s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
|
||||
{/* Price header */}
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-8 py-10 text-center">
|
||||
<div className="text-sm font-semibold text-teal-400 uppercase tracking-wide mb-2">
|
||||
Lifetime License
|
||||
</div>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-5xl font-extrabold text-white">
|
||||
{pricing ? formatPrice(currentPrice) : '...'}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-400 text-lg">/once</span>
|
||||
)}
|
||||
</div>
|
||||
{spotsRemaining > 0 && pricing && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '}
|
||||
remaining at this price
|
||||
</p>
|
||||
)}
|
||||
<p className="text-warm-300 text-sm mt-1">
|
||||
{isFree
|
||||
? 'Free for early adopters'
|
||||
: 'One-time payment, no subscription'}
|
||||
</p>
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-16">
|
||||
{/* Tier cards — full viewport width carousel */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : pricing ? (
|
||||
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
|
||||
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{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;
|
||||
|
||||
<div className="px-8 py-8">
|
||||
{/* Tier breakdown */}
|
||||
{pricing && (
|
||||
<div className="mb-8 space-y-1.5">
|
||||
<p className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
||||
Pricing tiers
|
||||
</p>
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? activeCardRef : undefined}
|
||||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||
isCurrent
|
||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||
: 'border-warm-700 shadow-md'
|
||||
} ${isFilled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
|
||||
Current tier
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
|
||||
<div
|
||||
className={`px-6 py-8 text-center ${
|
||||
isCurrent
|
||||
? 'bg-gradient-to-br from-navy-950 to-teal-900'
|
||||
: 'bg-white dark:bg-warm-800'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
|
||||
isCurrent
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
|
||||
: isFilled
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
? 'text-teal-300'
|
||||
: 'text-warm-500 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span
|
||||
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
|
||||
className={`text-4xl font-extrabold ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-navy-950 dark:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
{formatPrice(tier.price_pence)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="w-16 h-1.5 rounded-full bg-warm-200 dark:bg-warm-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-teal-500"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tier.price_pence > 0 && (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
className={`text-lg ${
|
||||
isCurrent
|
||||
? 'text-teal-700 dark:text-teal-400'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
? 'text-warm-400'
|
||||
: 'text-warm-400 dark:text-warm-500'
|
||||
}`}
|
||||
>
|
||||
{formatPrice(tier.price_pence)}
|
||||
/lifetime
|
||||
</span>
|
||||
{isFilled && (
|
||||
<CheckIcon className="w-4 h-4 text-warm-400 dark:text-warm-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="space-y-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot
|
||||
{spotsRemaining !== 1 ? 's' : ''} remaining
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> Filled
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-8 px-6 py-4 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
Open dashboard
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
)}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free license'
|
||||
: `Get started \u2014 ${formatPrice(currentPrice)}`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? 'Claim free license' : 'Get started'}
|
||||
</button>
|
||||
)}
|
||||
{/* Progress bar for current tier */}
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
|
||||
<div
|
||||
className="h-full bg-teal-500"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{license.error && (
|
||||
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
|
||||
{isFree
|
||||
? 'No credit card required'
|
||||
: '30-day money-back guarantee'}
|
||||
</p>
|
||||
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<>
|
||||
{ctaButton}
|
||||
{license.error && (
|
||||
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{isFree
|
||||
? 'No credit card required'
|
||||
: '30-day money-back guarantee'}
|
||||
</p>
|
||||
</>
|
||||
) : isFilled ? (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
|
||||
Sold out
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
|
||||
Upcoming
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-warm-400 py-16">
|
||||
Failed to load pricing. Please try again later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue