325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CheckIcon } from '../ui/icons/CheckIcon';
|
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
|
import type { AuthUser } from '../../hooks/useAuth';
|
|
import { useLicense } from '../../hooks/useLicense';
|
|
import { logNonAbortError } from '../../lib/api';
|
|
import { trackEvent } from '../../lib/analytics';
|
|
import { apiUrl } from '../../lib/api';
|
|
import HexCanvas from '../home/HexCanvas';
|
|
|
|
// Feature list keys — resolved inside the component via t()
|
|
|
|
interface PricingTier {
|
|
up_to: number | null;
|
|
price_pence: number;
|
|
slots: number;
|
|
}
|
|
|
|
interface PricingData {
|
|
licensed_count: number;
|
|
current_price_pence: number;
|
|
tiers: PricingTier[];
|
|
}
|
|
|
|
function formatPricePence(pence: number): string {
|
|
return `\u00A3${pence / 100}`;
|
|
}
|
|
|
|
export default function PricingPage({
|
|
onOpenDashboard,
|
|
user,
|
|
onLoginClick: _onLoginClick,
|
|
onRegisterClick,
|
|
}: {
|
|
onOpenDashboard: () => void;
|
|
user?: AuthUser | null;
|
|
onLoginClick?: () => void;
|
|
onRegisterClick?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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(() => {
|
|
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) => logNonAbortError('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 containerRect = container.getBoundingClientRect();
|
|
const cardRect = card.getBoundingClientRect();
|
|
const scrollLeft =
|
|
container.scrollLeft +
|
|
cardRect.left -
|
|
containerRect.left -
|
|
(container.clientWidth - card.offsetWidth) / 2;
|
|
container.scrollLeft = Math.max(0, scrollLeft);
|
|
setScrolledLeft(container.scrollLeft > 0);
|
|
}, [pricing, currentTierIndex]);
|
|
|
|
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"
|
|
>
|
|
{t('pricingPage.openDashboard')}
|
|
</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
|
|
? t('upgrade.redirecting')
|
|
: isFree
|
|
? t('upgrade.claimFreeAccess')
|
|
: t('pricingPage.getStartedPrice', { price: formatPricePence(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 ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
|
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
|
|
<HexCanvas isDark />
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
|
|
</div>
|
|
|
|
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
|
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
|
|
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
|
|
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
|
|
</div>
|
|
|
|
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md: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"
|
|
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="overflow-x-auto px-6 pb-4 scrollbar-hide"
|
|
style={{ scrollbarWidth: 'none' }}
|
|
>
|
|
<div className="flex w-max gap-6 mx-auto">
|
|
{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">
|
|
{t('pricingPage.currentTier')}
|
|
</div>
|
|
)}
|
|
|
|
<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 ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
|
|
}`}
|
|
>
|
|
{i === 0
|
|
? t('pricingPage.firstNUsers', { count: tier.slots })
|
|
: tier.up_to === null
|
|
? t('pricingPage.everyoneAfter')
|
|
: t('pricingPage.nextNUsers', { count: tier.slots })}
|
|
</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'
|
|
}`}
|
|
>
|
|
{tier.price_pence === 0
|
|
? t('upgrade.free')
|
|
: formatPricePence(tier.price_pence)}
|
|
</span>
|
|
{tier.price_pence > 0 && (
|
|
<span
|
|
className={`text-lg ${
|
|
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
|
|
}`}
|
|
>
|
|
{t('pricingPage.lifetime')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isCurrent && spotsRemaining > 0 && (
|
|
<p className="text-teal-300 text-sm mt-2 font-medium">
|
|
{spotsRemaining === 1
|
|
? t('pricingPage.spotsRemaining', { count: spotsRemaining })
|
|
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}
|
|
</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" /> {t('pricingPage.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">
|
|
{[
|
|
t('pricingPage.feat1'),
|
|
t('pricingPage.feat2'),
|
|
t('pricingPage.feat3'),
|
|
t('pricingPage.feat4'),
|
|
t('pricingPage.feat5'),
|
|
t('pricingPage.feat6'),
|
|
].map((feat, idx) => (
|
|
<li key={idx} 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">{feat}</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>
|
|
)}
|
|
{isFree && (
|
|
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
|
{t('pricingPage.noCreditCard')}
|
|
</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">
|
|
{t('pricingPage.soldOut')}
|
|
</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">
|
|
{t('pricingPage.upcoming')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-warm-400 py-16">{t('pricingPage.failedToLoad')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|