Translate pages

This commit is contained in:
Andras Schmelczer 2026-04-04 09:47:18 +01:00
parent a7aaf5effa
commit 96402228e3
49 changed files with 1458 additions and 926 deletions

View file

@ -1,4 +1,5 @@
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';
@ -7,14 +8,7 @@ import { logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const FEATURES = [
'56 data layers across England',
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Multiple decades of historical price data',
'Crime, schools, transport, broadband and more',
'All future data updates included',
];
// Feature list keys — resolved inside the component via t()
interface PricingTier {
up_to: number | null;
@ -28,17 +22,10 @@ interface PricingData {
tiers: PricingTier[];
}
function formatPrice(pence: number): string {
if (pence === 0) return 'Free';
function formatPricePence(pence: number): string {
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,
@ -50,6 +37,7 @@ export default function PricingPage({
onLoginClick?: () => void;
onRegisterClick?: () => void;
}) {
const { t } = useTranslation();
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
@ -109,7 +97,7 @@ export default function PricingPage({
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"
>
Open dashboard
{t('pricingPage.openDashboard')}
</button>
) : user ? (
<button
@ -119,17 +107,17 @@ export default function PricingPage({
>
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{license.checkingOut
? 'Redirecting...'
? t('upgrade.redirecting')
: isFree
? 'Claim free access'
: `Get started - ${formatPrice(currentPrice)}`}
? 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 ? 'Claim free access' : 'Get started'}
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
</button>
);
@ -194,20 +182,18 @@ export default function PricingPage({
</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">{t('pricingPage.title')}</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.
{t('pricingPage.subtitle')}
</p>
</div>
<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.
{t('pricingPage.costContext')}
</p>
<p className="text-warm-200 font-semibold">
Less than a home survey. Far more useful.
{t('pricingPage.lessThanSurvey')}
</p>
</div>
@ -267,7 +253,7 @@ export default function PricingPage({
>
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
Current tier
{t('pricingPage.currentTier')}
</div>
)}
@ -283,7 +269,11 @@ export default function PricingPage({
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
}`}
>
{tierLabel(tier, i)}
{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
@ -295,7 +285,7 @@ export default function PricingPage({
: 'text-navy-950 dark:text-warm-100'
}`}
>
{formatPrice(tier.price_pence)}
{tier.price_pence === 0 ? t('upgrade.free') : formatPricePence(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
<span
@ -303,20 +293,21 @@ export default function PricingPage({
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
}`}
>
/lifetime
{t('pricingPage.lifetime')}
</span>
)}
</div>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot
{spotsRemaining !== 1 ? 's' : ''} remaining
{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" /> Filled
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
</p>
)}
</div>
@ -330,10 +321,10 @@ export default function PricingPage({
<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">
{[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">{feature}</span>
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
</li>
))}
</ul>
@ -347,16 +338,16 @@ export default function PricingPage({
</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'}
{isFree ? t('pricingPage.noCreditCard') : t('pricingPage.moneyBackGuarantee')}
</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
{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">
Upcoming
{t('pricingPage.upcoming')}
</div>
)}
</div>
@ -367,7 +358,7 @@ export default function PricingPage({
</div>
) : (
<p className="text-center text-warm-400 py-16">
Failed to load pricing. Please try again later.
{t('pricingPage.failedToLoad')}
</p>
)}
</div>