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(null); const [loading, setLoading] = useState(true); const [scrolledLeft, setScrolledLeft] = useState(false); const scrollRef = useRef(null); const activeCardRef = useRef(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 ? ( ) : user ? ( ) : ( ); return (

{t('pricingPage.title')}

{t('pricingPage.subtitle')}

{t('pricingPage.lessThanSurvey')}

{/* Tier cards — full viewport width carousel */} {loading ? (
) : pricing ? (
{scrolledLeft && (
)}
{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 (
{isCurrent && (
{t('pricingPage.currentTier')}
)}

{i === 0 ? t('pricingPage.firstNUsers', { count: tier.slots }) : tier.up_to === null ? t('pricingPage.everyoneAfter') : t('pricingPage.nextNUsers', { count: tier.slots })}

{tier.price_pence === 0 ? t('upgrade.free') : formatPricePence(tier.price_pence)} {tier.price_pence > 0 && ( {t('pricingPage.lifetime')} )}
{isCurrent && spotsRemaining > 0 && (

{spotsRemaining === 1 ? t('pricingPage.spotsRemaining', { count: spotsRemaining }) : t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}

)} {isFilled && (

{t('pricingPage.filled')}

)}
{/* Progress bar for current tier */} {isCurrent && tierSlots > 0 && (
)}
    {[ t('pricingPage.feat1'), t('pricingPage.feat2'), t('pricingPage.feat3'), t('pricingPage.feat4'), t('pricingPage.feat5'), t('pricingPage.feat6'), ].map((feat, idx) => (
  • {feat}
  • ))}
{isCurrent ? ( <> {ctaButton} {license.error && (

{license.error}

)} {isFree && (

{t('pricingPage.noCreditCard')}

)} ) : isFilled ? (
{t('pricingPage.soldOut')}
) : (
{t('pricingPage.upcoming')}
)}
); })}
) : (

{t('pricingPage.failedToLoad')}

)}
); }