This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -42,7 +42,7 @@ function tierLabel(tier: PricingTier, index: number): string {
export default function PricingPage({
onOpenDashboard,
user,
onLoginClick,
onLoginClick: _onLoginClick,
onRegisterClick,
}: {
onOpenDashboard: () => void;
@ -87,8 +87,7 @@ export default function PricingPage({
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;
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
break;
}
}
@ -99,7 +98,8 @@ export default function PricingPage({
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
const scrollLeft =
card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
@ -117,9 +117,7 @@ export default function PricingPage({
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 && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{license.checkingOut
? 'Redirecting...'
: isFree
@ -143,7 +141,8 @@ export default function PricingPage({
<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%)',
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',
}}
/>
@ -151,7 +150,8 @@ export default function PricingPage({
<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%)',
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',
}}
/>
@ -159,7 +159,8 @@ export default function PricingPage({
<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%)',
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',
}}
/>
@ -167,7 +168,8 @@ export default function PricingPage({
<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%)',
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',
}}
/>
@ -175,7 +177,8 @@ export default function PricingPage({
<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%)',
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',
}}
/>
@ -183,16 +186,15 @@ export default function PricingPage({
<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%)',
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="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
Early access pricing
</h1>
<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">
Pay once, access forever. The earlier you join, the less you pay.
</p>
@ -200,9 +202,9 @@ export default function PricingPage({
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees,
&pound;500 for a survey. Get the wrong area and you&apos;re stuck with a long
commute, bad schools, or a road you didn&apos;t know about.
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees, &pound;500
for a survey. Get the wrong area and you&apos;re stuck with a long commute, bad schools,
or a road you didn&apos;t know about.
</p>
<p className="text-warm-200 font-semibold">
Less than your survey costs. Vastly more useful.
@ -216,145 +218,151 @@ export default function PricingPage({
<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 justify-center 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
<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 justify-center 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;
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
className={`px-6 py-8 text-center ${
key={i}
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`}
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
<p
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
Current tier
</div>
)}
<div
className={`px-6 py-8 text-center ${
isCurrent
? 'text-teal-300'
: 'text-warm-500 dark:text-warm-400'
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`}
>
{tierLabel(tier, i)}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
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'
<p
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
}`}
>
{formatPrice(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
{tierLabel(tier, i)}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
className={`text-lg ${
className={`text-4xl font-extrabold ${
isCurrent
? 'text-warm-400'
: 'text-warm-400 dark:text-warm-500'
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-navy-950 dark:text-warm-100'
}`}
>
/lifetime
{formatPrice(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
<span
className={`text-lg ${
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
}`}
>
/lifetime
</span>
)}
</div>
{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>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot
{spotsRemaining !== 1 ? 's' : ''} remaining
</p>
{/* 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>
)}
{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>
{/* 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>
)}
<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>
<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}
{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>
)}
<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>
)}
</>
) : 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>
) : (
@ -363,7 +371,6 @@ export default function PricingPage({
</p>
)}
</div>
</div>
);
}