From 9e4e65fa2a8ea4b979fedae0ca5acf5da8301dcf Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 12 Jul 2026 20:30:19 +0100 Subject: [PATCH] fine --- .gitignore | 2 +- docker-compose.yml | 7 + finder/price_history.py | 120 +++++++++++++++ finder/scraper.py | 15 +- finder/storage.py | 26 +++- finder/test_price_history.py | 77 ++++++++++ finder/test_scraper_concurrency.py | 4 +- finder/transform.py | 13 ++ frontend/src/App.tsx | 49 ++++-- .../account/ResetPasswordPage.test.tsx | 53 +++++++ .../components/account/ResetPasswordPage.tsx | 137 +++++++++++++++++ frontend/src/components/map/AiFilterInput.tsx | 8 +- .../map/filters/ActiveFiltersPanel.tsx | 4 +- .../components/map/filters/AddFilterPanel.tsx | 8 +- .../map/filters/EnumFeatureFilterCard.tsx | 2 +- .../map/filters/EthnicityFilterCard.tsx | 4 +- frontend/src/hooks/useAuth.ts | 43 +++++- frontend/src/i18n/locales/de.ts | 19 +++ frontend/src/i18n/locales/en.ts | 18 +++ frontend/src/i18n/locales/fr.ts | 19 +++ frontend/src/i18n/locales/hi.ts | 16 ++ frontend/src/i18n/locales/hu.ts | 19 +++ frontend/src/i18n/locales/zh.ts | 16 ++ frontend/src/lib/auth-errors.ts | 14 +- frontend/src/lib/postcode.ts | 22 +++ pipeline/transform/merge.py | 16 ++ server-rs/src/consts.rs | 4 + server-rs/src/data/actual_listings.rs | 103 +++++++++++++ server-rs/src/data/property/loading.rs | 36 +++++ server-rs/src/data/property/mod.rs | 37 ++++- server-rs/src/data/property/poi_metrics.rs | 143 +++++++++++++++++- server-rs/src/main.rs | 8 + server-rs/src/og_middleware.rs | 17 +++ server-rs/src/routes/hexagon_stats.rs | 22 +++ server-rs/src/routes/stats.rs | 141 +++++++++++++---- 35 files changed, 1172 insertions(+), 70 deletions(-) create mode 100644 finder/price_history.py create mode 100644 finder/test_price_history.py create mode 100644 frontend/src/components/account/ResetPasswordPage.test.tsx create mode 100644 frontend/src/components/account/ResetPasswordPage.tsx create mode 100644 frontend/src/lib/postcode.ts diff --git a/.gitignore b/.gitignore index 00b8193..3fc8f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,5 @@ video/auth.* r5-java/tmp property-data property-data-snapshot -property-data-snapshot2 +property-data-snapshot* video/.audit* diff --git a/docker-compose.yml b/docker-compose.yml index a0bcbe4..4f90327 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,13 @@ services: - cargo-home:/usr/local/cargo - cargo-target:/app/server-rs/target environment: + # Disable incremental compilation. cargo-watch does incremental + # rebuilds over the mounted source; editing a struct with a generic + # serde impl mid-flight can corrupt the incremental cache and produce + # `rust-lld: undefined hidden symbol ...::serialize` link failures + # that never resolve until a clean rebuild. Off = slightly slower + # warm rebuilds, but no such corruption. + CARGO_INCREMENTAL: "0" # Fallback only: the binary uses jemalloc as its global allocator # (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas. MALLOC_ARENA_MAX: "2" diff --git a/finder/price_history.py b/finder/price_history.py new file mode 100644 index 0000000..4b0683a --- /dev/null +++ b/finder/price_history.py @@ -0,0 +1,120 @@ +"""Forward-only asking-price history, accrued across recurring scrape runs. + +Rightmove (and the other portals) never expose a listing's full asking-price +timeline: a detail page carries only the current price plus one most-recent +"Reduced on " event, and the previous price is never published. The only +way to obtain a real "listed at X, reduced to Y" series is therefore to record +each listing's price ourselves every run and diff it over time. + +This module keeps a persistent, listing-id-keyed store of price observations: + + {"": [{"date": "YYYY-MM-DD", "price": 425000, "reason": "listed"}, + {"date": "YYYY-MM-DD", "price": 410000, "reason": "reduced"}]} + +Entries are oldest -> newest. A new point is appended only when the price +actually changes (or on first sight), so an unchanged listing costs nothing. The +store is seeded from / dumped to disk with the same atomic JSON helpers as the +detail-postcode caches (see postcode_cache.py), so a recurring scrape extends the +history rather than rebuilding it. + +Two hard limitations, both inherent to the data source: + * There is NO backfill. History accrues only from the first instrumented run; + prior asking prices cannot be reconstructed (portals don't publish them and + we kept no snapshots). + * On a listing's first sight we know exactly one price, so we record one point. + If the portal's own most-recent event says that price was itself a reduction/ + increase, we date and label that single point accordingly; we still cannot + invent the pre-change price. +""" + +import logging +import re +from pathlib import Path + +from postcode_cache import load_cache, save_cache + +log = logging.getLogger("rightmove") + +# Portal `listingUpdateReason` codes -> our canonical reasons. Anything not a +# recognised price move (e.g. "new", "under_offer", "auction", or absent) leaves +# the first-sight point labelled "listed" and never fabricates a change. +_REASON_MAP = { + "price_reduced": "reduced", + "price_increased": "increased", +} + +_ISO_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})") + + +def normalize_reason(raw: object) -> str | None: + """Map a portal update-reason code to 'reduced'/'increased', else None.""" + if not isinstance(raw, str): + return None + return _REASON_MAP.get(raw.strip().lower()) + + +def _iso_to_date(value: object) -> str | None: + """Return the YYYY-MM-DD prefix of an ISO timestamp, or None.""" + if not isinstance(value, str): + return None + match = _ISO_DATE_RE.match(value.strip()) + return match.group(1) if match else None + + +def load_history(path: str | Path) -> dict: + """Load the persisted asking-price history. Returns {} when absent/unreadable.""" + return load_cache(path) + + +def save_history(path: str | Path, history: dict) -> None: + """Atomically persist the asking-price history to disk.""" + save_cache(path, history) + + +def update_history(history: dict, listings: list[dict], run_date: str) -> None: + """Fold one run's listings into ``history`` in place. + + ``run_date`` is the ISO date (YYYY-MM-DD) of the scrape. For each listing with + a stable id and a positive price: + + * first sight -> append one point. Its date/reason come from the portal's + most-recent change event when that event is a price move (so a listing we + first meet already-reduced reads "Reduced on "); otherwise the + point is the "listed" price dated to ``first_visible_date`` (falling back + to ``run_date``). + * later runs -> append a point ONLY when the price differs from the last + recorded one, labelled "reduced"/"increased" by direction and dated to + ``run_date`` (we only know the change happened by this run). + + An unchanged price is a no-op, so the series stays a list of genuine moves. + """ + for listing in listings: + listing_id_raw = listing.get("id") + if listing_id_raw is None: + continue + listing_id = str(listing_id_raw).strip() + if not listing_id: + continue + try: + price = int(listing.get("price") or 0) + except (TypeError, ValueError): + continue + if price <= 0: + continue + + entries = history.get(listing_id) + if not isinstance(entries, list) or not entries: + reason = normalize_reason(listing.get("listing_update_reason")) + if reason is not None: + date = _iso_to_date(listing.get("listing_update_date")) or run_date + else: + reason = "listed" + date = _iso_to_date(listing.get("first_visible_date")) or run_date + history[listing_id] = [{"date": date, "price": price, "reason": reason}] + continue + + last_price = entries[-1].get("price") + if last_price == price: + continue + reason = "reduced" if last_price is not None and price < last_price else "increased" + entries.append({"date": run_date, "price": price, "reason": reason}) diff --git a/finder/scraper.py b/finder/scraper.py index 6130de8..a9c7f60 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -5,6 +5,7 @@ import signal import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager +from datetime import datetime, timezone from functools import partial from pathlib import Path from typing import Callable, Iterable @@ -34,6 +35,7 @@ from postcode_cache import load_cache, save_cache from rightmove import resolve_outcode_id from rightmove import search_outcode as rightmove_search_outcode from spatial import PostcodeSpatialIndex +from price_history import load_history, save_history, update_history from storage import write_parquet from zoopla import TurnstileError from zoopla import launch_browser as launch_zoopla_browser @@ -839,7 +841,18 @@ def run_scrape( merged, source_counts, deduped = _merge_properties(results) output_path = output_base / "online_listings_buy.parquet" if merged: - write_parquet(merged, output_path) + # Accrue the per-listing asking-price history before writing: load the + # persistent store, append this run's price moves, dump it, then embed + # each listing's series in the parquet. Forward-only by nature — the + # first run seeds one point per listing and reductions appear over time. + history_path = output_base / "price_history" / "listings.json" + history = load_history(history_path) + run_date = ( + datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%d") + ) + update_history(history, merged, run_date) + save_history(history_path, history) + write_parquet(merged, output_path, price_history=history) else: if output_path.exists(): output_path.unlink() diff --git a/finder/storage.py b/finder/storage.py index a7b051a..c885160 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode log = logging.getLogger("rightmove") -def write_parquet(properties: list[dict], path: Path) -> None: - """Write sale properties list to parquet with server-ready column names.""" +def write_parquet( + properties: list[dict], path: Path, price_history: dict | None = None +) -> None: + """Write sale properties list to parquet with server-ready column names. + + ``price_history`` is the persistent listing-id -> [{date, price, reason}] + store (see finder/price_history.py); each listing's accrued asking-price + series is embedded as a ``price_history`` list column. Absent id -> empty + list, so pre-instrumentation runs and tests simply write empty series. + """ if not properties: log.warning("No properties to write to %s", path) return @@ -95,6 +103,14 @@ def write_parquet(properties: list[dict], path: Path) -> None: asking_prices = [p["price"] if p["price"] > 0 else None for p in properties] listing_statuses = ["For sale"] * len(properties) + # Accrued asking-price history per listing (oldest -> newest). Look up with + # the SAME normalisation the store keys on (str(id).strip(), matching + # price_history.update_history and scraper dedup); an unseen id -> empty. + history_lookup = price_history or {} + price_history_col = [ + history_lookup.get(str(p.get("id")).strip(), []) for p in properties + ] + df = pl.DataFrame( { "Bedrooms": [p["Bedrooms"] for p in properties], @@ -144,6 +160,7 @@ def write_parquet(properties: list[dict], path: Path) -> None: "Listing date": listing_dates, "Listing status": listing_statuses, "Asking price": asking_prices, + "price_history": price_history_col, }, schema={ "Bedrooms": pl.Int32, @@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None: "Listing date": pl.Datetime("us"), "Listing status": pl.Utf8, "Asking price": pl.Int64, + "price_history": pl.List( + pl.Struct( + {"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8} + ) + ), }, ) diff --git a/finder/test_price_history.py b/finder/test_price_history.py new file mode 100644 index 0000000..7dbbd96 --- /dev/null +++ b/finder/test_price_history.py @@ -0,0 +1,77 @@ +"""Tests for the forward-only asking-price history store (price_history.py).""" + +from price_history import normalize_reason, update_history + + +def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None: + history: dict = {} + update_history( + history, + [{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}], + "2026-07-12", + ) + assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}] + + +def test_first_sight_already_reduced_uses_event_date_and_reason() -> None: + history: dict = {} + update_history( + history, + [ + { + "id": "B", + "price": 425000, + "first_visible_date": "2026-06-01T09:00:00Z", + "listing_update_reason": "price_reduced", + "listing_update_date": "2026-07-10T14:00:00Z", + } + ], + "2026-07-12", + ) + assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}] + + +def test_later_run_appends_only_on_price_change_with_direction() -> None: + history: dict = {} + listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"} + update_history(history, [listing], "2026-07-12") + # Unchanged price -> no new point. + update_history(history, [{"id": "A", "price": 500000}], "2026-07-19") + assert len(history["A"]) == 1 + # Reduced -> appended, dated to the run. + update_history(history, [{"id": "A", "price": 480000}], "2026-07-26") + # Increased -> appended. + update_history(history, [{"id": "A", "price": 490000}], "2026-08-02") + assert history["A"] == [ + {"date": "2026-07-01", "price": 500000, "reason": "listed"}, + {"date": "2026-07-26", "price": 480000, "reason": "reduced"}, + {"date": "2026-08-02", "price": 490000, "reason": "increased"}, + ] + + +def test_zero_or_missing_price_and_id_are_skipped() -> None: + history: dict = {} + update_history( + history, + [ + {"id": "POA", "price": 0}, + {"id": "", "price": 100000}, + {"price": 100000}, # no id + ], + "2026-07-12", + ) + assert history == {} + + +def test_run_date_fallback_when_dates_absent() -> None: + history: dict = {} + update_history(history, [{"id": "A", "price": 300000}], "2026-07-12") + assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}] + + +def test_normalize_reason_maps_only_price_moves() -> None: + assert normalize_reason("price_reduced") == "reduced" + assert normalize_reason("price_increased") == "increased" + assert normalize_reason("new") is None + assert normalize_reason("under_offer") is None + assert normalize_reason(None) is None diff --git a/finder/test_scraper_concurrency.py b/finder/test_scraper_concurrency.py index 24df360..140ed60 100644 --- a/finder/test_scraper_concurrency.py +++ b/finder/test_scraper_concurrency.py @@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey monkeypatch.setattr( scraper, "write_parquet", - lambda props, path: written.update(count=len(props), path=path), + lambda props, path, price_history=None: written.update( + count=len(props), path=path, price_history=price_history + ), ) result = scraper.run_scrape( diff --git a/finder/transform.py b/finder/transform.py index 2a05dd4..a0adc05 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -385,6 +385,16 @@ def transform_property( if not listing_id: return None + # The search API already carries the single most-recent price-change event + # (reason + ISO date) with no extra request. Rightmove never exposes the + # previous price, so this only seeds the accruing asking-price history (see + # finder/price_history.py); it is not written to the output parquet itself. + listing_update = prop.get("listingUpdate") + if not isinstance(listing_update, dict): + listing_update = {} + listing_update_reason = listing_update.get("listingUpdateReason") + listing_update_date = listing_update.get("listingUpdateDate") + return { "id": listing_id, "Bedrooms": bedrooms, @@ -411,4 +421,7 @@ def transform_property( "Listing URL": build_listing_url(property_url, bool(prop.get("development"))), "Listing features": key_features, "first_visible_date": prop.get("firstVisibleDate", ""), + # Fed into the asking-price history store, not the parquet directly. + "listing_update_reason": listing_update_reason, + "listing_update_date": listing_update_date, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3bfd1f..898bde6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); @@ -283,6 +298,7 @@ export default function App() { loginWithOAuth, logout, requestPasswordReset, + confirmPasswordReset, refreshAuth, clearError, } = useAuth(); @@ -708,6 +724,17 @@ export default function App() { ) : activePage === 'terms' || activePage === 'privacy' ? ( + ) : activePage === 'reset-password' ? ( + { + navigateTo('home'); + openAuthModal('login'); + }} + loading={authLoading} + error={authError} + onClearError={clearError} + /> ) : isSeoLandingPage(activePage) ? ( 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={() => { diff --git a/frontend/src/components/account/ResetPasswordPage.test.tsx b/frontend/src/components/account/ResetPasswordPage.test.tsx new file mode 100644 index 0000000..0b0d17d --- /dev/null +++ b/frontend/src/components/account/ResetPasswordPage.test.tsx @@ -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()), + 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( + {}} + /> + ); + 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'); + }); +}); diff --git a/frontend/src/components/account/ResetPasswordPage.tsx b/frontend/src/components/account/ResetPasswordPage.tsx new file mode 100644 index 0000000..012fd06 --- /dev/null +++ b/frontend/src/components/account/ResetPasswordPage.tsx @@ -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; + 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 ( +
+
+

+ {t('auth.resetPassword')} +

+ + {!token ? ( +
+

{t('auth.resetMissingToken')}

+ +
+ ) : done ? ( +
+

{t('auth.resetSuccessBody')}

+ +
+ ) : ( +
+
+ +
+ 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')} + /> + +
+
+ + {error &&

{error}

} + + + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index fabb9e7..a472e1f 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -143,11 +143,11 @@ export default memo(function AiFilterInput({ if (!expanded) { return ( -
+
diff --git a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx index 8fc69a8..6ff3cbf 100644 --- a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx +++ b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx @@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({ return (
diff --git a/frontend/src/components/map/filters/EthnicityFilterCard.tsx b/frontend/src/components/map/filters/EthnicityFilterCard.tsx index fcea4aa..5d981bd 100644 --- a/frontend/src/components/map/filters/EthnicityFilterCard.tsx +++ b/frontend/src/components/map/filters/EthnicityFilterCard.tsx @@ -119,7 +119,7 @@ export function EthnicityFilterCard({ return (
-