From 80a5a2a774ab82a3a594cce0f8f665a3bb2d6646 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 10 Mar 2026 22:05:51 +0000 Subject: [PATCH] Lots of improvements --- .../src/components/account/AccountPage.tsx | 195 +++++---------- frontend/src/components/learn/LearnPage.tsx | 10 +- frontend/src/components/ui/FeatureLabel.tsx | 15 ++ frontend/src/components/ui/InfoPopup.tsx | 4 +- frontend/src/hooks/useMapData.ts | 5 +- frontend/src/hooks/useTheme.ts | 4 + frontend/src/index.css | 5 + frontend/src/lib/consts.ts | 6 +- frontend/src/lib/external-search.ts | 2 +- frontend/src/lib/format.ts | 3 +- frontend/src/lib/rightmove-outcodes.json | 1 + frontend/src/types.ts | 8 +- pipeline/download/places.py | 12 +- pipeline/download/rightmove_outcodes.py | 74 ++++++ pipeline/download/transit_network.py | 236 +++++++++++++++++- server-rs/src/data/property.rs | 30 +-- server-rs/src/features.rs | 122 ++++----- server-rs/src/main.rs | 22 +- server-rs/src/routes.rs | 8 +- server-rs/src/routes/ai_filters.rs | 6 +- server-rs/src/routes/hexagons.rs | 58 +---- 21 files changed, 489 insertions(+), 337 deletions(-) create mode 100644 frontend/src/lib/rightmove-outcodes.json create mode 100644 pipeline/download/rightmove_outcodes.py diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index d9e3bbd..579ede0 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -20,13 +20,6 @@ const ACCOUNT_TABS = [ { key: 'settings', label: 'Settings' }, ]; -const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const; - -const SUBSCRIPTION_LABELS: Record = { - free: 'Free', - licensed: 'Licensed', -}; - function SavedSearchesContent({ searches, loading, @@ -196,10 +189,6 @@ function SettingsContent({ onRefreshAuth: () => Promise; onRequestVerification: (email: string) => Promise; }) { - const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free'); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [error, setError] = useState(null); const [newsletterSaving, setNewsletterSaving] = useState(false); const [newsletterError, setNewsletterError] = useState(null); @@ -207,65 +196,50 @@ function SettingsContent({ const [verificationSending, setVerificationSending] = useState(false); const [verificationSent, setVerificationSent] = useState(false); - // Invite state - const [creatingInvite, setCreatingInvite] = useState(false); - const [inviteUrl, setInviteUrl] = useState(null); - const [inviteError, setInviteError] = useState(null); - const [inviteCopied, setInviteCopied] = useState(false); + // Invite state — keyed by invite type for admins (who can create both kinds) + const [creatingInvite, setCreatingInvite] = useState>({}); + const [inviteUrl, setInviteUrl] = useState>({}); + const [inviteError, setInviteError] = useState>({}); + const [inviteCopied, setInviteCopied] = useState>({}); - const handleSave = async () => { - setSaving(true); - setError(null); - setSaved(false); - try { - const res = await fetch(apiUrl('subscription'), { - method: 'PATCH', - ...authHeaders({ - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ subscription: selectedSubscription }), - }), - }); - assertOk(res, 'Update subscription'); - await onRefreshAuth(); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to update subscription'; - setError(msg); - } finally { - setSaving(false); - } - }; - - const handleCreateInvite = async () => { - setCreatingInvite(true); - setInviteError(null); - setInviteUrl(null); - setInviteCopied(false); + const handleCreateInvite = async (type: string) => { + setCreatingInvite((prev) => ({ ...prev, [type]: true })); + setInviteError((prev) => { + const next = { ...prev }; + delete next[type]; + return next; + }); + setInviteUrl((prev) => { + const next = { ...prev }; + delete next[type]; + return next; + }); + setInviteCopied((prev) => ({ ...prev, [type]: false })); try { const res = await fetch(apiUrl('invites'), { method: 'POST', ...authHeaders({ headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ invite_type: type }), }), }); assertOk(res, 'Create invite'); const data = await res.json(); - setInviteUrl(data.url); + setInviteUrl((prev) => ({ ...prev, [type]: data.url })); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to create invite'; - setInviteError(msg); + setInviteError((prev) => ({ ...prev, [type]: msg })); } finally { - setCreatingInvite(false); + setCreatingInvite((prev) => ({ ...prev, [type]: false })); } }; - const handleCopyInvite = () => { - if (!inviteUrl) return; - copyToClipboard(inviteUrl, () => { - setInviteCopied(true); - setTimeout(() => setInviteCopied(false), 2000); + const handleCopyInvite = (type: string) => { + const url = inviteUrl[type]; + if (!url) return; + copyToClipboard(url, () => { + setInviteCopied((prev) => ({ ...prev, [type]: true })); + setTimeout(() => setInviteCopied((prev) => ({ ...prev, [type]: false })), 2000); }); }; @@ -323,7 +297,7 @@ function SettingsContent({

Subscription

- {SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'} + {user.subscription === 'licensed' ? 'Licensed' : 'Free'}
@@ -369,83 +343,48 @@ function SettingsContent({ {/* Invite friends */} - {isLicensed && ( -
-

- {user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'} -

- {inviteUrl ? ( -
- + {isLicensed && + (user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => ( +
+

+ {type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'} +

+ {inviteUrl[type] ? ( +
+ + +
+ ) : ( -
- ) : ( - - )} - {inviteError && ( -

{inviteError}

- )} -
- )} - - {/* Admin section */} - {user.isAdmin && ( -
-

- Admin: Change subscription -

-
- - + )} + {inviteError[type] && ( +

{inviteError[type]}

+ )}
- {error && ( -

{error}

- )} -
- )} + ))} +
{/* Support */} diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 5b76e10..056b920 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -48,7 +48,7 @@ const DATA_SOURCES = [ id: 'ethnicity', name: 'Population by Ethnicity (2021 Census)', origin: 'ONS', - use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.', + use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.', url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data', license: 'Open Government Licence v3.0', }, @@ -60,14 +60,6 @@ const DATA_SOURCES = [ url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0', }, - { - id: 'tfl-journey-times', - name: 'TfL Journey Times', - origin: 'Transport for London', - use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.", - url: 'https://api-portal.tfl.gov.uk/', - license: 'Powered by TfL Open Data', - }, { id: 'osm-pois', name: 'OpenStreetMap POIs', diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 5c4487f..702aba6 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -2,6 +2,12 @@ import type { FeatureMeta } from '../../types'; import { InfoIcon } from './icons'; import { getGroupIcon } from '../../lib/group-icons'; +const MODE_LABELS: Record = { + historical: 'Historical', + buy: 'Buy', + rent: 'Rent', +}; + interface FeatureLabelProps { feature: FeatureMeta; onShowInfo?: (feature: FeatureMeta) => void; @@ -17,6 +23,10 @@ export function FeatureLabel({ }: FeatureLabelProps) { const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; const GroupIcon = feature.group ? getGroupIcon(feature.group) : null; + const modeTag = + feature.modes && feature.modes.length > 0 + ? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ') + : null; return (
{feature.name} + {modeTag && ( + + {modeTag} + + )} {feature.detail && onShowInfo && (