Translate pages
This commit is contained in:
parent
a7aaf5effa
commit
96402228e3
49 changed files with 1458 additions and 926 deletions
|
|
@ -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 £10k+ in stamp duty, £1,500 in solicitor fees, £500
|
||||
for a survey. Get the wrong area and you're stuck with a long commute, bad schools,
|
||||
or a road you didn'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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue