This commit is contained in:
Andras Schmelczer 2026-07-12 20:30:19 +01:00
parent 6df2812a4e
commit 9e4e65fa2a
35 changed files with 1172 additions and 70 deletions

View file

@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
@ -38,6 +38,7 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
const AccountPage = lazy(() => import('./components/account/AccountPage'));
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
@ -160,6 +161,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/saved';
case 'account':
return '/account';
case 'reset-password':
return '/reset-password';
case 'invite':
if (!inviteCode) {
throw new Error('Cannot build invite path without an invite code');
@ -183,6 +186,7 @@ function pathToPage(rawPathname: string): RouteMatch | null {
const seoContentPage = getSeoContentPage(pathname);
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/reset-password') return { page: 'reset-password' };
if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
@ -234,15 +238,26 @@ export default function App() {
return params.get('og') === '1';
}, []);
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
const initialMapFilters = useMemo(() => {
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
return Object.keys(mapUrlState.filters).length === 0
? { ...DEFAULT_DEMO_FILTERS }
: mapUrlState.filters;
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
const isColdMapOpen = useMemo(() => {
if (isScreenshotMode || isOgMode) return false;
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
return !hasFilters && !hasTravel;
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
const initialMapFilters = useMemo(
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
[isColdMapOpen, mapUrlState.filters]
);
const initialMapTravelTime = useMemo(
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
[isColdMapOpen, mapUrlState.travelTime]
);
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
@ -283,6 +298,7 @@ export default function App() {
loginWithOAuth,
logout,
requestPasswordReset,
confirmPasswordReset,
refreshAuth,
clearError,
} = useAuth();
@ -708,6 +724,17 @@ export default function App() {
<LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : activePage === 'reset-password' ? (
<ResetPasswordPage
onConfirm={confirmPasswordReset}
onLoginClick={() => {
navigateTo('home');
openAuthModal('login');
}}
loading={authLoading}
error={authError}
onClearError={clearError}
/>
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
@ -769,7 +796,7 @@ export default function App() {
}}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}
initialTravelTime={initialMapTravelTime}
initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
onSessionRejected={() => {

View file

@ -0,0 +1,53 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
import ResetPasswordPage from './ResetPasswordPage';
function renderPage(search: string, onConfirm = vi.fn(async () => {})) {
window.history.replaceState({}, '', `/reset-password${search}`);
const onLoginClick = vi.fn();
render(
<ResetPasswordPage
onConfirm={onConfirm}
onLoginClick={onLoginClick}
loading={false}
error={null}
onClearError={() => {}}
/>
);
return { onConfirm, onLoginClick };
}
afterEach(cleanup);
describe('ResetPasswordPage', () => {
it('shows an incomplete-link message and no form when the token is missing', () => {
const { onLoginClick } = renderPage('');
expect(screen.getByText('auth.resetMissingToken')).toBeTruthy();
expect(screen.queryByLabelText('auth.newPassword')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'auth.goToLogin' }));
expect(onLoginClick).toHaveBeenCalledOnce();
});
it('submits the URL token plus the new password, then shows success', async () => {
const { onConfirm } = renderPage('?token=reset-token-123');
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'sup3rs3cret' } });
fireEvent.click(screen.getByRole('button', { name: 'auth.updatePassword' }));
expect(onConfirm).toHaveBeenCalledWith('reset-token-123', 'sup3rs3cret');
expect(await screen.findByText('auth.resetSuccessBody')).toBeTruthy();
});
it('toggles password visibility', () => {
renderPage('?token=abc');
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
expect(input.type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
expect(input.type).toBe('text');
});
});

View file

@ -0,0 +1,137 @@
import { useCallback, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { EyeOffIcon } from '../ui/icons/EyeOffIcon';
/**
* Landing page for the PocketBase password-reset email link
* (`/reset-password?token=…`). Reads the one-time token from the URL, takes a new
* password, and calls `confirmPasswordReset`. PocketBase does not issue a session
* on confirm, so on success we point the user at the log in screen rather than
* auto-authenticating them.
*/
export default function ResetPasswordPage({
onConfirm,
onLoginClick,
loading,
error,
onClearError,
}: {
onConfirm: (token: string, password: string) => Promise<void>;
onLoginClick: () => void;
loading: boolean;
error: string | null;
onClearError: () => void;
}) {
const { t } = useTranslation();
const token = useMemo(
() => new URLSearchParams(window.location.search).get('token') ?? '',
[]
);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [done, setDone] = useState(false);
const fieldId = useId();
const passwordInputId = `${fieldId}-new-password`;
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
onClearError();
try {
await onConfirm(token, password);
setDone(true);
} catch {
// Error surfaces through the `error` prop (set by useAuth).
}
},
[onConfirm, token, password, onClearError]
);
const loginButtonClass =
'w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 transition-colors text-center';
return (
<div className="flex-1 flex items-start justify-center bg-warm-50 dark:bg-navy-950 px-4 py-16">
<div className="w-full max-w-sm bg-white dark:bg-warm-900 rounded-lg shadow-sm border border-warm-200 dark:border-warm-700 p-6">
<h1 className="text-lg font-semibold text-navy-950 dark:text-white mb-4">
{t('auth.resetPassword')}
</h1>
{!token ? (
<div className="space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-300">{t('auth.resetMissingToken')}</p>
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
{t('auth.goToLogin')}
</button>
</div>
) : done ? (
<div className="space-y-4">
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSuccessBody')}</p>
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
{t('auth.goToLogin')}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={passwordInputId}
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
>
{t('auth.newPassword')}
</label>
<div className="relative">
<input
id={passwordInputId}
name="new-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
autoFocus
className="w-full pl-3 pr-10 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={t('auth.newPasswordPlaceholder')}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
aria-pressed={showPassword}
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
>
{showPassword ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon filled={false} className="w-5 h-5" />
)}
</button>
</div>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<button
type="submit"
disabled={loading}
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 ? t('auth.pleaseWait') : t('auth.updatePassword')}
</button>
<button
type="button"
onClick={onLoginClick}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('auth.backToLogin')}
</button>
</form>
)}
</div>
</div>
);
}

View file

@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
if (!expanded) {
return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
>
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
}
return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')}

View file

@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
onLoginRequired={onLoginRequired}
/>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}

View file

@ -166,7 +166,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-b border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
@ -179,7 +179,7 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
{!collapsed && (
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -197,7 +197,7 @@ export function AddFilterPanel({
</div>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
<div className="mt-auto shrink-0 flex flex-col items-center gap-1.5 px-3 pt-2 pb-2 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
<span className="text-xs text-warm-400 dark:text-warm-500">
@ -206,7 +206,7 @@ export function AddFilterPanel({
</p>
<button
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
className="w-full px-5 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
</button>

View file

@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-start justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />

View file

@ -119,7 +119,7 @@ export function EthnicityFilterCard({
return (
<div
data-filter-name={ETHNICITIES_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${
className={`space-y-1 px-2 py-1 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.ethnicity')}
</label>
<div className="relative">

View file

@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
return status === 401 || status === 403;
}
/**
* Normalize an email so login and registration are case- and
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
* resolve to the same account. Applied at the PocketBase boundary rather
* than on the input field, so the user still sees exactly what they typed.
*/
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function useAuth() {
const { t } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(() => {
@ -62,7 +72,9 @@ export function useAuth() {
setError(null);
setErrorAction(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
const result = await pb
.collection('users')
.authWithPassword(normalizeEmail(email), password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
@ -82,12 +94,13 @@ export function useAuth() {
setLoading(true);
setError(null);
setErrorAction(null);
const normalizedEmail = normalizeEmail(email);
try {
// Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists").
try {
await pb.collection('users').create({
email,
email: normalizedEmail,
password,
passwordConfirm: password,
newsletter: true,
@ -103,7 +116,7 @@ export function useAuth() {
// orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed".
try {
const result = await pb.collection('users').authWithPassword(email, password);
const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
@ -159,7 +172,7 @@ export function useAuth() {
setError(null);
setErrorAction(null);
try {
await pb.collection('users').requestPasswordReset(email);
await pb.collection('users').requestPasswordReset(normalizeEmail(email));
} catch (err) {
const { message, action } = resolveAuthError(err, 'reset', t);
setError(message);
@ -172,6 +185,27 @@ export function useAuth() {
[t]
);
const confirmPasswordReset = useCallback(
async (token: string, password: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
// PocketBase requires the password twice; the reset form uses a single
// (show/hide) field, so both arguments are the same value.
await pb.collection('users').confirmPasswordReset(token, password, password);
} catch (err) {
const { message, action } = resolveAuthError(err, 'confirmReset', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const refreshAuth = useCallback(async () => {
setLoading(true);
setError(null);
@ -208,6 +242,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
confirmPasswordReset,
refreshAuth,
clearError,
};

View file

@ -646,6 +646,16 @@ const de: Translations = {
registrationFailed: 'Registrierung fehlgeschlagen',
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
newPassword: 'Neues Passwort',
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
updatePassword: 'Passwort aktualisieren',
resetSuccessBody:
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
resetMissingToken:
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
resetInvalidLink:
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
goToLogin: 'Zur Anmeldung',
},
// ── Upgrade Modal ──────────────────────────────────
@ -953,6 +963,10 @@ const de: Translations = {
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung',
priceHistoryThisArea: 'Dieses Gebiet',
priceMetric: 'Preisbasis',
priceMetricTotal: 'Gesamt',
priceMetricPerSqm: 'Pro m²',
journeysFrom: 'Fahrzeiten für {{label}}',
to: 'Nach {{destination}}',
noJourneyData: 'Keine Verbindungsdaten verfügbar',
@ -1578,6 +1592,10 @@ const de: Translations = {
listing: 'Inserat',
showingOf: '{{visible}} von {{count}} werden angezeigt',
groupedNear: 'Gruppiert an dieser Kartenposition',
priceHistory: 'Preisverlauf',
priceListed: 'Gelistet',
priceReduced: 'Reduziert',
priceIncreased: 'Erhöht',
},
overlays: {
@ -1898,6 +1916,7 @@ const de: Translations = {
'Tube station': 'U-Bahn-Station',
'DLR station': 'DLR-Station',
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
'Any station': 'Beliebige Station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -633,6 +633,15 @@ const en = {
registrationFailed: 'Registration failed',
oauthFailed: 'OAuth login failed',
passwordResetFailed: 'Password reset request failed',
newPassword: 'New password',
newPasswordPlaceholder: 'At least 8 characters',
updatePassword: 'Update password',
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
resetMissingToken:
'This password reset link is incomplete. Request a new one from the log in screen.',
resetInvalidLink:
'This reset link is invalid or has expired. Request a new one from the log in screen.',
goToLogin: 'Go to log in',
},
// ── Upgrade Modal ──────────────────────────────────
@ -941,6 +950,10 @@ const en = {
viewProperties: 'View {{count}} properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
priceHistoryThisArea: 'This area',
priceMetric: 'Price basis',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Per m²',
journeysFrom: 'Journey times for {{label}}',
to: 'To {{destination}}',
noJourneyData: 'No journey data available',
@ -1558,6 +1571,10 @@ const en = {
listing: 'Listing',
showingOf: 'Showing {{visible}} of {{count}}',
groupedNear: 'Grouped near this map position',
priceHistory: 'Price history',
priceListed: 'Listed',
priceReduced: 'Reduced',
priceIncreased: 'Increased',
},
// ── Map Overlays Pane ──────────────────────────────
@ -1880,6 +1897,7 @@ const en = {
'Tube station': 'Tube station',
'DLR station': 'DLR station',
'Tram & Metro stop': 'Tram & Metro stop',
'Any station': 'Any station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -657,6 +657,16 @@ const fr: Translations = {
registrationFailed: 'Échec de linscription',
oauthFailed: 'Échec de la connexion OAuth',
passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe',
newPassword: 'Nouveau mot de passe',
newPasswordPlaceholder: 'Au moins 8 caractères',
updatePassword: 'Mettre à jour le mot de passe',
resetSuccessBody:
'Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
resetMissingToken:
'Ce lien de réinitialisation est incomplet. Demandez-en un nouveau depuis lécran de connexion.',
resetInvalidLink:
'Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau depuis lécran de connexion.',
goToLogin: 'Aller à la connexion',
},
// ── Upgrade Modal ──────────────────────────────────
@ -963,6 +973,10 @@ const fr: Translations = {
viewProperties: 'Voir {{count}} biens',
viewPropertiesShort: 'Voir les biens',
priceHistory: 'Historique des prix',
priceHistoryThisArea: 'Cette zone',
priceMetric: 'Base de prix',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Au m²',
journeysFrom: 'Temps de trajet pour {{label}}',
to: 'Vers {{destination}}',
noJourneyData: 'Aucune donnée de trajet disponible',
@ -1595,6 +1609,10 @@ const fr: Translations = {
listing: 'Annonce',
showingOf: 'Affichage de {{visible}} sur {{count}}',
groupedNear: 'Regroupées près de cette position sur la carte',
priceHistory: 'Historique des prix',
priceListed: 'Mis en vente',
priceReduced: 'Réduit',
priceIncreased: 'Augmenté',
},
// ── Overlays ───────────────────────────────────────
@ -1918,6 +1936,7 @@ const fr: Translations = {
'Tube station': 'Station de métro londonien',
'DLR station': 'Station DLR',
'Tram & Metro stop': 'Arrêt de tramway et métro',
'Any station': 'Toute station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -630,6 +630,13 @@ const hi: Translations = {
registrationFailed: 'पंजीकरण विफल रहा',
oauthFailed: 'OAuth लॉग इन विफल रहा',
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
newPassword: 'नया पासवर्ड',
newPasswordPlaceholder: 'कम से कम 8 अक्षर',
updatePassword: 'पासवर्ड अपडेट करें',
resetSuccessBody: 'आपका पासवर्ड बदल दिया गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।',
resetMissingToken: 'यह रीसेट लिंक अधूरा है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
resetInvalidLink: 'यह रीसेट लिंक अमान्य है या समाप्त हो चुका है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
goToLogin: 'लॉग इन पर जाएँ',
},
upgrade: {
@ -921,6 +928,10 @@ const hi: Translations = {
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
priceHistoryThisArea: 'यह क्षेत्र',
priceMetric: 'मूल्य आधार',
priceMetricTotal: 'कुल',
priceMetricPerSqm: 'प्रति मी²',
journeysFrom: '{{label}} के लिए यात्रा समय',
to: '{{destination}} तक',
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
@ -1513,6 +1524,10 @@ const hi: Translations = {
listing: 'लिस्टिंग',
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
priceHistory: 'मूल्य इतिहास',
priceListed: 'सूचीबद्ध',
priceReduced: 'घटाया गया',
priceIncreased: 'बढ़ाया गया',
},
overlays: {
@ -1802,6 +1817,7 @@ const hi: Translations = {
'Tube station': 'ट्यूब स्टेशन',
'DLR station': 'DLR स्टेशन',
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
'Any station': 'कोई भी स्टेशन',
Café: 'कैफे',
Restaurant: 'रेस्तरां',
Pub: 'पब',

View file

@ -648,6 +648,16 @@ const hu: Translations = {
registrationFailed: 'A regisztráció sikertelen',
oauthFailed: 'Az OAuth-bejelentkezés sikertelen',
passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen',
newPassword: 'Új jelszó',
newPasswordPlaceholder: 'Legalább 8 karakter',
updatePassword: 'Jelszó frissítése',
resetSuccessBody:
'A jelszavad megváltozott. Mostantól bejelentkezhetsz az új jelszavaddal.',
resetMissingToken:
'Ez a jelszó-visszaállító link hiányos. Kérj egy újat a bejelentkezési képernyőről.',
resetInvalidLink:
'Ez a visszaállító link érvénytelen vagy lejárt. Kérj egy újat a bejelentkezési képernyőről.',
goToLogin: 'Tovább a bejelentkezéshez',
},
// ── Upgrade Modal ──────────────────────────────────
@ -951,6 +961,10 @@ const hu: Translations = {
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
priceHistory: 'Ártörténet',
priceHistoryThisArea: 'Ez a terület',
priceMetric: 'Ár alapja',
priceMetricTotal: 'Teljes',
priceMetricPerSqm: 'm²-enként',
journeysFrom: 'Utazási idők ehhez: {{label}}',
to: 'Ide: {{destination}}',
noJourneyData: 'Nincs elérhető utazási adat',
@ -1578,6 +1592,10 @@ const hu: Translations = {
listing: 'Hirdetés',
showingOf: '{{visible}} / {{count}} megjelenítve',
groupedNear: 'Ennél a térképponton csoportosítva',
priceHistory: 'Ártörténet',
priceListed: 'Meghirdetve',
priceReduced: 'Csökkentve',
priceIncreased: 'Növelve',
},
// ── Overlays ───────────────────────────────────────
@ -1900,6 +1918,7 @@ const hu: Translations = {
'Tube station': 'Londoni metróállomás',
'DLR station': 'DLR-állomás',
'Tram & Metro stop': 'Villamos- és metrómegálló',
'Any station': 'Bármely állomás',
Café: 'Kávézó',
Restaurant: 'Étterem',
Pub: 'Kocsma',

View file

@ -594,6 +594,13 @@ const zh: Translations = {
registrationFailed: '注册失败',
oauthFailed: 'OAuth 登录失败',
passwordResetFailed: '密码重置请求失败',
newPassword: '新密码',
newPasswordPlaceholder: '至少 8 个字符',
updatePassword: '更新密码',
resetSuccessBody: '您的密码已更改。现在可以使用新密码登录。',
resetMissingToken: '此重置链接不完整。请从登录界面重新获取。',
resetInvalidLink: '此重置链接无效或已过期。请从登录界面重新获取。',
goToLogin: '前往登录',
},
// ── Upgrade Modal ──────────────────────────────────
@ -889,6 +896,10 @@ const zh: Translations = {
viewProperties: '查看 {{count}} 套房产',
viewPropertiesShort: '查看房产',
priceHistory: '价格历史',
priceHistoryThisArea: '此区域',
priceMetric: '价格基准',
priceMetricTotal: '总价',
priceMetricPerSqm: '每平方米',
journeysFrom: '{{label}} 的出行时间',
to: '前往 {{destination}}',
noJourneyData: '暂无出行数据',
@ -1494,6 +1505,10 @@ const zh: Translations = {
listing: '房源',
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
groupedNear: '按此地图位置聚合',
priceHistory: '价格记录',
priceListed: '上架',
priceReduced: '降价',
priceIncreased: '涨价',
},
// ── Overlays ───────────────────────────────────────
@ -1813,6 +1828,7 @@ const zh: Translations = {
'Tube station': '伦敦地铁站',
'DLR station': 'DLR 轻轨站',
'Tram & Metro stop': '有轨电车与城市轨道站',
'Any station': '任意车站',
Café: '咖啡馆',
Restaurant: '餐厅',
Pub: '酒吧',

View file

@ -8,7 +8,7 @@ import type { TFunction } from 'i18next';
*/
export type AuthErrorAction = 'switchToLogin' | null;
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset';
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset' | 'confirmReset';
export interface ResolvedAuthError {
/** User-facing message (already resolved to a string). */
@ -75,5 +75,17 @@ export function resolveAuthError(
return { message: t('auth.oauthFailed'), action: null };
}
// Submitting a new password from the emailed reset link. A weak password comes
// back as a field error; anything else (bad/expired/used token) is a dead link.
if (context === 'confirmReset') {
if (fieldCode(e, 'password')) {
return { message: t('auth.errorPasswordWeak'), action: null };
}
if (status === 400 || status === 404) {
return { message: t('auth.resetInvalidLink'), action: null };
}
return { message: t('auth.passwordResetFailed'), action: null };
}
return { message: t('auth.passwordResetFailed'), action: null };
}

View file

@ -0,0 +1,22 @@
/**
* Postcode-component helpers, mirroring the server's `postcode_outcode` /
* `postcode_sector` (server-rs/src/utils.rs) so labels match the aggregations.
*/
/** Outward code of a postcode: the part before the space. "E14 2DG" -> "E14". */
export function postcodeOutcode(postcode: string): string | null {
const trimmed = postcode.trim().toUpperCase();
const space = trimmed.indexOf(' ');
return space > 0 ? trimmed.slice(0, space) : null;
}
/**
* Postcode sector: the outward code, the space, and the first character of the
* inward code. "E14 2DG" -> "E14 2".
*/
export function postcodeSector(postcode: string): string | null {
const trimmed = postcode.trim().toUpperCase();
const space = trimmed.indexOf(' ');
if (space <= 0 || space + 1 >= trimmed.length) return null;
return trimmed.slice(0, space + 2);
}