fine
This commit is contained in:
parent
6df2812a4e
commit
9e4e65fa2a
35 changed files with 1172 additions and 70 deletions
|
|
@ -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={() => {
|
||||
|
|
|
|||
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -657,6 +657,16 @@ const fr: Translations = {
|
|||
registrationFailed: 'Échec de l’inscription',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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: 'पब',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '酒吧',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
22
frontend/src/lib/postcode.ts
Normal file
22
frontend/src/lib/postcode.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue