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, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
@ -26,6 +27,7 @@ export default function AuthModal({
onClearError: () => void;
initialTab?: 'login' | 'register';
}) {
const { t } = useTranslation();
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@ -78,7 +80,11 @@ export default function AuthModal({
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
view === 'login'
? t('auth.logIn')
: view === 'register'
? t('auth.createAccount')
: t('auth.resetPassword');
return (
<div
@ -111,7 +117,7 @@ export default function AuthModal({
}`}
onClick={() => switchView('login')}
>
Log in
{t('auth.logIn')}
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
@ -121,7 +127,7 @@ export default function AuthModal({
}`}
onClick={() => switchView('register')}
>
Create account
{t('auth.createAccount')}
</button>
</div>
)}
@ -130,7 +136,7 @@ export default function AuthModal({
{/* Value prop */}
{view !== 'forgot' && (
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
Save searches, bookmark properties, and pick up where you left off.
{t('auth.valueProp')}
</p>
)}
@ -145,14 +151,14 @@ export default function AuthModal({
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
>
<GoogleIcon className="w-4 h-4" />
Continue with Google
{t('auth.continueWithGoogle')}
</button>
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
<span className="text-xs text-warm-400 dark:text-warm-500">{t('common.or')}</span>
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
</div>
</>
@ -162,7 +168,7 @@ export default function AuthModal({
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
{t('auth.email')}
</label>
<input
type="email"
@ -170,14 +176,14 @@ export default function AuthModal({
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
/>
</div>
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
{t('auth.password')}
</label>
<input
type="password"
@ -186,7 +192,7 @@ export default function AuthModal({
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
placeholder={view === 'register' ? t('auth.passwordPlaceholderRegister') : t('auth.passwordPlaceholderLogin')}
/>
{view === 'login' && (
<button
@ -194,7 +200,7 @@ export default function AuthModal({
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
{t('auth.forgotPassword')}
</button>
)}
</div>
@ -202,7 +208,7 @@ export default function AuthModal({
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
{t('auth.resetSent')}
</p>
)}
@ -215,12 +221,12 @@ export default function AuthModal({
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
? t('auth.pleaseWait')
: view === 'login'
? 'Log in'
? t('auth.logIn')
: view === 'register'
? 'Create account'
: 'Send reset link'}
? t('auth.createAccount')
: t('auth.sendResetLink')}
</button>
)}
@ -230,7 +236,7 @@ export default function AuthModal({
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
{t('auth.backToLogin')}
</button>
)}
</form>

View file

@ -1,3 +1,4 @@
import { ts } from '../../i18n/server';
import { ChevronIcon } from './icons/ChevronIcon';
interface CollapsibleGroupHeaderProps {
@ -20,7 +21,7 @@ export function CollapsibleGroupHeader({
onClick={onToggle}
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
>
<span>{name}</span>
<span>{ts(name)}</span>
<div className="flex items-center gap-1">
{children}
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-4 h-4" />

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
@ -21,8 +22,9 @@ export function DestinationDropdown({
onSelect,
onClear,
value,
placeholder = 'Select destination...',
placeholder,
}: DestinationDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
@ -129,7 +131,7 @@ export function DestinationDropdown({
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder="Type to filter..."
placeholder={t('travel.typeToFilter')}
className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
</div>
@ -138,7 +140,7 @@ export function DestinationDropdown({
<div ref={listRef} className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
{loading ? 'Loading...' : 'No destinations found'}
{loading ? t('common.loading') : t('travel.noDestinations')}
</div>
) : (
filtered.map((dest, idx) => (
@ -190,7 +192,7 @@ export function DestinationDropdown({
<span
className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}
>
{value || placeholder}
{value || placeholder || t('travel.selectDestination')}
</span>
</button>
{value && onClear ? (
@ -198,7 +200,7 @@ export function DestinationDropdown({
type="button"
onClick={onClear}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear destination"
title={t('travel.clearDestination')}
>
<CloseIcon className="w-3 h-3" />
</button>

View file

@ -1,4 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../types';
import { ts, tsDesc } from '../../i18n/server';
import InfoPopup from './InfoPopup';
interface FeatureInfoPopupProps {
@ -8,14 +10,15 @@ interface FeatureInfoPopupProps {
}
export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) {
const { t } = useTranslation();
return (
<InfoPopup
title={feature.name}
title={ts(feature.name)}
onClose={onClose}
sourceLink={
feature.source && onNavigateToSource
? {
label: 'View data source',
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource(feature.source!, feature.name);
onClose();
@ -25,7 +28,9 @@ export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: Featu
}
>
{feature.description && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
{tsDesc(feature.name, feature.description)}
</p>
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">

View file

@ -1,14 +1,10 @@
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../types';
import { ts, tsDesc } from '../../i18n/server';
import { InfoIcon } from './icons';
import { getFeatureIcon } from '../../lib/feature-icons';
import { getGroupIcon } from '../../lib/group-icons';
const MODE_LABELS: Record<string, string> = {
historical: 'Historical',
buy: 'Buy',
rent: 'Rent',
};
interface FeatureLabelProps {
feature: FeatureMeta;
onShowInfo?: (feature: FeatureMeta) => void;
@ -26,22 +22,31 @@ export function FeatureLabel({
description,
hideIconOnMobile,
}: FeatureLabelProps) {
const { t } = useTranslation();
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
const featureIcon = getFeatureIcon(feature.name, iconClass);
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
const modeLabels: Record<string, string> = {
historical: t('filters.historical'),
buy: t('filters.buy'),
rent: t('filters.rent'),
};
const modeTag =
feature.modes && feature.modes.length > 0
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
? feature.modes.map((m) => modeLabels[m] || m).join(' \u00B7 ')
: null;
const translatedName = ts(feature.name);
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
const nameContent = (
<>
<span
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
>
{feature.name}
{translatedName}
</span>
{modeTag && (
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
@ -52,7 +57,7 @@ export function FeatureLabel({
<button
onClick={() => onShowInfo(feature)}
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
title="Feature info"
title={t('filters.featureInfo')}
>
<InfoIcon className="w-3.5 h-3.5" />
</button>
@ -66,10 +71,10 @@ export function FeatureLabel({
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
{description ? (
{translatedDesc ? (
<div className="min-w-0">
<div className="flex items-center gap-1">{nameContent}</div>
<span className="text-xs text-warm-400 dark:text-warm-500 block">{description}</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">{translatedDesc}</span>
</div>
) : (
nameContent

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
@ -13,6 +14,7 @@ import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
import LanguageDropdown from './LanguageDropdown';
export type Page =
| 'home'
@ -64,6 +66,7 @@ export default function Header({
onLogout: () => void;
isMobile: boolean;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@ -131,7 +134,7 @@ export default function Header({
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcode</span>
<span className="font-semibold text-lg">{t('header.appName')}</span>
</a>
{/* Desktop nav */}
@ -142,7 +145,7 @@ export default function Header({
className={tabClass('dashboard')}
onClick={(e) => navLink('dashboard', e)}
>
Dashboard
{t('header.dashboard')}
</a>
{user && (
<a
@ -150,7 +153,7 @@ export default function Header({
className={tabClass('invites')}
onClick={(e) => navLink('invites', e)}
>
Invite Friends
{t('header.inviteFriends')}
</a>
)}
<a
@ -158,7 +161,7 @@ export default function Header({
className={tabClass('learn')}
onClick={(e) => navLink('learn', e)}
>
Learn
{t('header.learn')}
</a>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<a
@ -166,7 +169,7 @@ export default function Header({
className={tabClass('pricing')}
onClick={(e) => navLink('pricing', e)}
>
Pricing
{t('header.pricing')}
</a>
)}
</nav>
@ -186,17 +189,17 @@ export default function Header({
{sharing ? (
<>
<SpinnerIcon className="w-4 h-4 animate-spin" />
Sharing...
{t('header.sharing')}
</>
) : copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied!
{t('common.copied')}
</>
) : (
<>
<ClipboardIcon className="w-4 h-4" />
Share
{t('common.share')}
</>
)}
</button>
@ -204,10 +207,10 @@ export default function Header({
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
title={t('header.exportToExcel')}
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
{onSaveSearch && (
<button
@ -220,7 +223,7 @@ export default function Header({
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
{t('common.save')}
</button>
)}
</>
@ -231,7 +234,7 @@ export default function Header({
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
Saved
{t('header.saved')}
</a>
)}
@ -252,13 +255,13 @@ export default function Header({
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
{t('header.logIn')}
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Create account
{t('header.createAccount')}
</button>
</>
)}
@ -271,16 +274,19 @@ export default function Header({
onClick={onRegisterClick}
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
>
Create account
{t('header.createAccount')}
</button>
)}
{/* Language selector (desktop) */}
{!isMobile && <LanguageDropdown />}
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
{!isMobile && !user && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
title={`Theme: ${theme}`}
title={theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
>
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
@ -291,7 +297,7 @@ export default function Header({
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Open menu"
aria-label={t('header.openMenu')}
>
<MenuIcon className="w-6 h-6" />
</button>
@ -322,7 +328,7 @@ export default function Header({
{isMobile && copied && (
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<CheckIcon className="w-4 h-4 text-teal-400" />
Copied to clipboard
{t('common.copiedToClipboard')}
</div>
)}
</header>

View file

@ -1,10 +1,12 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface LicenseSuccessModalProps {
onClose: () => void;
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
const { t } = useTranslation();
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -50,18 +52,18 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="text-5xl mb-3">🎉</div>
<h2 className="text-2xl font-bold text-white">You&apos;re in.</h2>
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
<h2 className="text-2xl font-bold text-white">{t('licenseSuccess.title')}</h2>
<p className="text-warm-300 text-sm mt-2">{t('licenseSuccess.subtitle')}</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
Full access to every feature, every postcode, across all of England.
{t('licenseSuccess.description')}
</p>
<button
onClick={onClose}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Start exploring
{t('licenseSuccess.startExploring')}
</button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
@ -16,6 +17,7 @@ export default function SaveSearchModal({
saving: boolean;
error: string | null;
}) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
@ -50,7 +52,7 @@ export default function SaveSearchModal({
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{saved ? 'Search saved' : 'Save Search'}
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
</h2>
<button
onClick={onClose}
@ -65,7 +67,7 @@ export default function SaveSearchModal({
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
<CheckIcon className="w-5 h-5" />
<p className="text-sm text-warm-700 dark:text-warm-300">
Your search has been saved successfully.
{t('saveSearch.savedSuccess')}
</p>
</div>
<div className="flex gap-3 justify-end">
@ -74,14 +76,14 @@ export default function SaveSearchModal({
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Close
{t('common.close')}
</button>
<button
type="button"
onClick={onViewSearches}
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
>
View saved searches
{t('saveSearch.viewSavedSearches')}
</button>
</div>
</div>
@ -89,14 +91,14 @@ export default function SaveSearchModal({
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
{t('saveSearch.name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
@ -109,7 +111,7 @@ export default function SaveSearchModal({
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
{t('common.cancel')}
</button>
<button
type="submit"
@ -117,7 +119,7 @@ export default function SaveSearchModal({
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
{saving ? t('saveSearch.saving') : t('common.save')}
</button>
</div>
</form>

View file

@ -16,7 +16,7 @@ export function Slider({ className, ...props }: SliderProps) {
{props.value?.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
className="block h-6 w-6 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 before:absolute before:left-1/2 before:top-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:h-11 before:w-11 before:rounded-full before:content-['']"
/>
))}
</SliderPrimitive.Root>

View file

@ -1,13 +1,6 @@
import { useTranslation } from 'react-i18next';
import InfoPopup from './InfoPopup';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
const MODE_INFO: Record<TransportMode, string> = {
transit:
' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
car: ' by car, based on typical road speeds and the road network.',
bicycle: ' by bicycle, using cycle-friendly routes.',
walking: ' on foot, using pedestrian paths and pavements.',
};
import { useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
export function TravelTimeInfoPopup({
mode,
@ -16,11 +9,14 @@ export function TravelTimeInfoPopup({
mode: TransportMode;
onClose: () => void;
}) {
const { t } = useTranslation();
const modes = useTranslatedModes();
return (
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={onClose}>
<InfoPopup title={t('travel.travelTime', { mode: modes.label(mode) })} onClose={onClose}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Shows how long it takes to reach the selected destination from each area
{MODE_INFO[mode]} Use the slider to set your maximum commute time.
{t('travelInfo.mainDesc')}
{modes.desc(mode)} {t('travelInfo.sliderHint')}
</p>
</InfoPopup>
);

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl, logNonAbortError } from '../../lib/api';
@ -18,6 +19,7 @@ export default function UpgradeModal({
onStartCheckout,
onZoomToFreeZone,
}: UpgradeModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
@ -32,7 +34,7 @@ export default function UpgradeModal({
}, []);
const priceLabel =
pricePence === null ? '...' : pricePence === 0 ? 'Free' : `\u00A3${pricePence / 100}`;
pricePence === null ? '...' : pricePence === 0 ? t('upgrade.free') : `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
@ -41,7 +43,7 @@ export default function UpgradeModal({
try {
await onStartCheckout();
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed');
setError(err instanceof Error ? err.message : t('upgrade.checkoutFailed'));
} finally {
setLoading(false);
}
@ -60,10 +62,9 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">See all of England</h2>
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<p className="text-warm-300 text-sm">
You&apos;re currently exploring inner London. Get lifetime access to every postcode,
every filter, every neighbourhood. One payment, forever.
{t('upgrade.description')}
</p>
</div>
@ -73,12 +74,12 @@ export default function UpgradeModal({
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>}
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? 'Free for early adopters. No credit card required.'
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
? t('upgrade.freeForEarly')
: t('upgrade.oneTimePayment')}
</p>
{isLoggedIn ? (
@ -89,10 +90,10 @@ export default function UpgradeModal({
>
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{loading
? 'Redirecting...'
? t('upgrade.redirecting')
: isFree
? 'Claim free access'
: `Upgrade for ${priceLabel}`}
? t('upgrade.claimFreeAccess')
: t('upgrade.upgradeFor', { price: priceLabel })}
</button>
) : (
<div className="flex flex-col gap-3">
@ -100,13 +101,13 @@ export default function UpgradeModal({
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Register & Upgrade
{t('upgrade.registerAndUpgrade')}
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
{t('upgrade.alreadyHaveAccount')}
</button>
</div>
)}
@ -119,7 +120,7 @@ export default function UpgradeModal({
onClick={onZoomToFreeZone}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
Continue exploring inner London
{t('upgrade.continueWithDemo')}
</button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import type { Page } from './Header';
import { PAGE_PATHS } from './Header';
@ -18,6 +19,7 @@ export default function UserMenu({
onLogout: () => void;
onNavigate: (page: Page) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -59,7 +61,9 @@ export default function UserMenu({
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
}`}
>
{user.subscription === 'licensed' || user.isAdmin ? 'Full Access' : 'Inner London'}
{user.subscription === 'licensed' || user.isAdmin
? t('userMenu.fullAccess')
: t('userMenu.demo')}
</span>
</div>
</div>
@ -73,8 +77,9 @@ export default function UserMenu({
) : (
<MoonIcon className="w-4 h-4" />
)}
Theme: {theme === 'light' ? 'Light' : 'Dark'}
{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
</button>
<a
href={PAGE_PATHS.account}
onClick={(e) => {
@ -85,7 +90,7 @@ export default function UserMenu({
}}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
{t('userMenu.account')}
</a>
<button
onClick={() => {
@ -94,7 +99,7 @@ export default function UserMenu({
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Log out
{t('userMenu.logOut')}
</button>
</div>
</div>

View file

@ -0,0 +1,18 @@
interface IconProps {
className?: string;
}
export function LocateIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="4" strokeLinecap="round" strokeLinejoin="round" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
);
}

View file

@ -14,6 +14,7 @@ export { GraduationCapIcon } from './GraduationCapIcon';
export { HouseIcon } from './HouseIcon';
export { InfoIcon } from './InfoIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { LocateIcon } from './LocateIcon';
export { LogoIcon } from './LogoIcon';
export { MapPinIcon } from './MapPinIcon';
export { MenuIcon } from './MenuIcon';