This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -1,4 +1,9 @@
import { useState, useEffect } 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 { apiUrl } from '../../lib/api';
const FEATURES = [
'56 data layers across England',
@ -9,20 +14,82 @@ const FEATURES = [
'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<PricingData | null>(null);
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));
}, []);
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;
}
}
}
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">
One price. Yours forever.
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.
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
</p>
</div>
@ -33,33 +100,147 @@ export default function PricingPage({
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
<span className="text-5xl font-extrabold text-white">
{pricing ? formatPrice(currentPrice) : '...'}
</span>
{!isFree && (
<span className="text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
{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>
{/* Features list */}
<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}
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
isCurrent
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
: isFilled
? 'opacity-50'
: ''
}`}
>
<span
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
>
{tierLabel(tier, i)}
</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>
)}
<span
className={`font-semibold ${
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'
}`}
>
{formatPrice(tier.price_pence)}
</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>
<span className="text-warm-700 dark:text-warm-300">
{feature}
</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
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"
>
Get started
</button>
{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>
)}
{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">
30-day money-back guarantee
{isFree
? 'No credit card required'
: '30-day money-back guarantee'}
</p>
</div>
</div>