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, 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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
18
frontend/src/components/ui/icons/LocateIcon.tsx
Normal file
18
frontend/src/components/ui/icons/LocateIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue