lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue