diff --git a/Makefile.data b/Makefile.data index 278c8bd..5eb8eb5 100644 --- a/Makefile.data +++ b/Makefile.data @@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/certificates.csv -JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet -JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet CRIME_DIR := $(MANUAL_DATA)/crime CRIME := $(DATA_DIR)/crime_by_lsoa.parquet @@ -68,8 +66,7 @@ PMTILES_VERSION := 1.22.3 download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-school-proximity transform-geosure transform-postcode-boundaries \ - generate-postcode-boundaries \ - journey-times + generate-postcode-boundaries prepare: $(PRICES_STAMP) merge: $(MERGE_STAMP) @@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF) $(PLACES): $(PBF) uv run python -m pipeline.download.places --output $@ --pbf $(PBF) -# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── - -$(JT_BANK): - @echo "" - @echo "=== TFL journey times (bank) not found ===" - @echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank" - @echo "" - @exit 1 - -$(JT_FITZROVIA): - @echo "" - @echo "=== TFL journey times (fitzrovia) not found ===" - @echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia" - @echo "" - @exit 1 - -journey-times: $(ARCGIS) -ifndef DEST - $(error DEST required — e.g. make journey-times DEST=bank) -endif - uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS) - # ── Transforms ──────────────────────────────────────────────────────────────── $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) @@ -256,15 +227,13 @@ $(PC_BOUNDARIES): # ── Final merge → postcode.parquet + properties.parquet ────────────────────── -$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ +$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ --arcgis $(ARCGIS) \ --iod $(IOD) \ --poi-proximity $(POI_PROXIMITY) \ - --journey-times-bank $(JT_BANK) \ - --journey-times-fitzrovia $(JT_FITZROVIA) \ --ethnicity $(ETHNICITY) \ --crime $(CRIME) \ --noise $(NOISE) \ diff --git a/README.md b/README.md index 4bdcb70..0c90747 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip https://xploria.co.uk/data-sources/ - - - --- - stripe diff --git a/Taskfile.yml b/Taskfile.yml index eb43540..834e34e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,11 +16,6 @@ tasks: cmds: - uv run python -m pipeline.download.map_assets --output frontend/public/assets - download:places: - desc: Extract place names from OSM PBF - cmds: - - uv run python -m pipeline.download.places --output ./property-data/places.parquet {{.CLI_ARGS}} - test: desc: Run all tests (Python and Rust) cmds: @@ -45,10 +40,6 @@ tasks: cmds: - docker compose up --build - - - - build:server: desc: Build server for production dir: server-rs diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8d58db..d22c801 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import LicenseSuccessModal from './components/ui/LicenseSuccessModal'; import VerificationBanner from './components/ui/VerificationBanner'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; import { fetchWithRetry, apiUrl } from './lib/api'; +import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; @@ -77,7 +78,10 @@ export default function App() { const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); - const [inviteCode, setInviteCode] = useState(null); + const [inviteCode, setInviteCode] = useState(() => { + const fromPath = pathToPage(window.location.pathname); + return fromPath?.inviteCode ?? null; + }); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; @@ -88,16 +92,13 @@ export default function App() { // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; + // Unknown path — track as 404 + if (window.location.pathname !== '/') { + trackEvent('404', { path: window.location.pathname }); + } return 'home'; }); - useEffect(() => { - const fromPath = pathToPage(window.location.pathname); - if (fromPath?.inviteCode) { - setInviteCode(fromPath.inviteCode); - } - }, []); - const { theme, toggleTheme } = useTheme(); const isMobile = useIsMobile(); const { @@ -126,6 +127,7 @@ export default function App() { ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; window.history.replaceState({}, '', newUrl); + trackEvent('Purchase'); setShowLicenseSuccess(true); refreshAuth(); } @@ -230,7 +232,7 @@ export default function App() { setShowSaveModal(false)} onSave={savedSearches.saveSearch} + onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }} saving={savedSearches.saving} error={savedSearches.error} /> )} {showLicenseSuccess && ( - setShowLicenseSuccess(false)} /> + { setShowLicenseSuccess(false); navigateTo('dashboard'); }} /> )} ); diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 2ec003f..a255c05 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api'; +import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime } from '../../lib/format'; import { summarizeParams } from '../../lib/url-state'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; @@ -42,37 +43,24 @@ function SavedSearchesContent({ setDeleteConfirmId(null); }, [deleteConfirmId, onDelete]); - const copyToClipboard = useCallback((text: string, id: string) => { - const onSuccess = () => { + const doCopy = useCallback((text: string, id: string) => { + copyToClipboard(text, () => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } + }); }, []); const handleShare = useCallback(async (params: string, id: string) => { setSharingId(id); try { const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl, id); + doCopy(shortUrl, id); } catch { - copyToClipboard(`${window.location.origin}/?${params}`, id); + doCopy(`${window.location.origin}/?${params}`, id); } finally { setSharingId(null); } - }, [copyToClipboard]); + }, [doCopy]); return ( <> @@ -270,7 +258,7 @@ function SettingsContent({ const handleCopyInvite = () => { if (!inviteUrl) return; - navigator.clipboard.writeText(inviteUrl).then(() => { + copyToClipboard(inviteUrl, () => { setInviteCopied(true); setTimeout(() => setInviteCopied(false), 2000); }); @@ -284,7 +272,7 @@ function SettingsContent({ const isLicensed = user.subscription === 'licensed' || user.isAdmin; return ( -
+
{/* Email */}
@@ -380,7 +368,7 @@ function SettingsContent({ {isLicensed && (

- {user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'} + {user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}

{inviteUrl ? (
@@ -409,7 +397,7 @@ function SettingsContent({ className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2" > {creatingInvite && } - {user.isAdmin ? 'Generate invite link' : 'Generate referral link'} + {user.isAdmin ? 'Generate free invite link' : 'Generate referral link'} )} {inviteError && ( @@ -455,6 +443,20 @@ function SettingsContent({
)}
+ + {/* Support */} +
+

Need help? Email us at

+ + support@propertymap.co.uk + +

+ We typically respond within 24 hours. +

+
); } diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 719575c..aec4acf 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useFadeInRef } from '../../hooks/useFadeIn'; import HexCanvas from './HexCanvas'; import ScrollStory from './ScrollStory'; @@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; import { ChevronIcon } from '../ui/icons/ChevronIcon'; import { LogoIcon } from '../ui/icons/LogoIcon'; +import { trackEvent } from '../../lib/analytics'; import type { FeatureMeta } from '../../types'; export default function HomePage({ @@ -30,6 +31,35 @@ export default function HomePage({ const whyRef = useFadeInRef(); const ctaRef = useFadeInRef(); + // Scroll depth tracking + const scrolledSections = useRef(new Set()); + useEffect(() => { + const ids = ['how-it-works', 'demo']; + const observers: IntersectionObserver[] = []; + ids.forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !scrolledSections.current.has(id)) { + scrolledSections.current.add(id); + trackEvent('Scroll Depth', { section: id }); + } + }, + { threshold: 0.1 } + ); + observer.observe(el); + observers.push(observer); + }); + return () => observers.forEach((o) => o.disconnect()); + }, []); + + // 30s time-on-page event + useEffect(() => { + const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000); + return () => clearTimeout(timer); + }, []); + return (
@@ -54,13 +84,17 @@ export default function HomePage({

+ diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 9b6cf5c..3608d3a 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: return (
-
+ setQuery(e.target.value)} - placeholder="Describe your ideal property..." - className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" + placeholder="Describe your ideal property and area..." + className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" disabled={loading} />
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index ba90d25..704385c 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -109,6 +109,11 @@ export default function AreaPane({ {propertyCount.toLocaleString()} properties

)} +

+ Stats for {isPostcode ? 'current and historical' : 'all'} properties + in this {isPostcode ? 'postcode' : 'area'} + {Object.keys(filters).length > 0 ? ' matching all active filters' : ''} +

{!isPostcode && stats && (
) : null}
diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx index c43f84c..3b742c8 100644 --- a/frontend/src/components/map/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -1,10 +1,35 @@ -import { useMemo } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import type { FeatureFilters } from '../../types'; import { buildPropertySearchUrls, H3_RADIUS_MILES, type HexagonLocation, } from '../../lib/external-search'; +import { apiUrl, logNonAbortError } from '../../lib/api'; + +function useRightmoveLocationId(postcode: string | undefined): string | undefined { + const [locationId, setLocationId] = useState(); + + useEffect(() => { + if (!postcode) { + setLocationId(undefined); + return; + } + setLocationId(undefined); + const controller = new AbortController(); + fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), { + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.location_identifier) setLocationId(data.location_identifier); + }) + .catch((err) => logNonAbortError('rightmove-location', err)); + return () => controller.abort(); + }, [postcode]); + + return locationId; +} export default function ExternalSearchLinks({ location, @@ -13,29 +38,46 @@ export default function ExternalSearchLinks({ location: HexagonLocation; filters: FeatureFilters; }) { - const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]); - const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1; + const rightmoveLocationId = useRightmoveLocationId(location.postcode); + const urls = useMemo( + () => buildPropertySearchUrls({ location, filters, rightmoveLocationId }), + [location, filters, rightmoveLocationId] + ); + const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1); const label = `${radiusMiles}mi radius`; + if (!urls) return null; + + const linkClass = + 'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium'; + const disabledClass = + 'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default'; + return (

Search {label} on

- - Rightmove - + {urls.rightmove ? ( + + Rightmove + + ) : ( + + Rightmove + + )} OnTheMarket @@ -43,7 +85,7 @@ export default function ExternalSearchLinks({ href={urls.zoopla} target="_blank" rel="noopener noreferrer" - className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium" + className={linkClass} > Zoopla diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 8d2ca62..289b24c 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,9 +9,17 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons'; +import type { ComponentType } from 'react'; import { IconButton } from '../ui/IconButton'; -import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; + +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; interface FeatureBrowserProps { availableFeatures: FeatureMeta[]; @@ -24,6 +32,8 @@ interface FeatureBrowserProps { onClearOpenInfoFeature?: () => void; travelTimeEntries: TravelTimeEntry[]; onAddTravelTimeEntry: (mode: TransportMode) => void; + isLicensed: boolean; + onUpgradeClick?: () => void; } export default function FeatureBrowser({ @@ -37,6 +47,8 @@ export default function FeatureBrowser({ onClearOpenInfoFeature, travelTimeEntries, onAddTravelTimeEntry, + isLicensed, + onUpgradeClick, }: FeatureBrowserProps) { const [search, setSearch] = useState(''); const [infoFeature, setInfoFeature] = useState(null); @@ -77,23 +89,21 @@ export default function FeatureBrowser({ name="Travel Time" expanded={isSearching || expandedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {TRANSPORT_MODES.length} {(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => { - const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug); - const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null; - const isPinned = fieldKey !== null && pinnedFeature === fieldKey; + const ModeIcon = MODE_ICONS[mode]; return (
onAddTravelTimeEntry(mode)}> - +
{MODE_LABELS[mode]} @@ -104,16 +114,6 @@ export default function FeatureBrowser({
- {fieldKey && ( - onTogglePin(fieldKey)} - active={isPinned} - title={isPinned ? 'Unpin color view' : 'Color map by this feature'} - size="md" - > - - - )} onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md"> @@ -131,7 +131,7 @@ export default function FeatureBrowser({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {group.features.length} @@ -174,10 +174,31 @@ export default function FeatureBrowser({ } className="px-3 py-4" /> - ) : ( + ) : isLicensed ? (

Everyone cares about different things. Pick the filters that matter most to you.

+ ) : ( +
+

+ The biggest financial decision of your life deserves proper tools behind it. +

+

+ Don't leave it to chance. +

+ + + + + + + +
)}
{infoFeature && ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 57c06db..e3ad939 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -25,27 +25,20 @@ import { type ListingType = 'historical' | 'buy' | 'rent'; -const MODE_RESTRICTED_FEATURES: Record> = { - 'Bathrooms': new Set(['buy', 'rent']), -}; - -function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean { - const allowed = MODE_RESTRICTED_FEATURES[featureName]; - return !allowed || allowed.has(mode); -} - function SliderLabels({ min, max, value, displayValues, - absoluteMax, + isAtMin, + isAtMax, }: { min: number; max: number; value: [number, number]; displayValues?: [number, number]; - absoluteMax?: boolean; + isAtMin?: boolean; + isAtMax?: boolean; }) { const range = max - min || 1; const leftPct = ((value[0] - min) / range) * 100; @@ -57,13 +50,13 @@ function SliderLabels({ className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }} > - {formatFilterValue(labels[0])} + {isAtMin ? 'min' : formatFilterValue(labels[0])} - {formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''} + {isAtMax ? 'max' : formatFilterValue(labels[1])}
); @@ -87,15 +80,18 @@ interface FiltersProps { openInfoFeature?: string | null; onClearOpenInfoFeature?: () => void; travelTimeEntries: TravelTimeEntry[]; - travelTimeDataRanges: Map; onTravelTimeAddEntry: (mode: TransportMode) => void; onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeToggleBest: (index: number) => void; aiFilterLoading: boolean; aiFilterError: string | null; aiFilterNotes: string | null; onAiFilterSubmit: (query: string) => void; + isLicensed: boolean; + onUpgradeClick?: () => void; + onResetTutorial?: () => void; } export default memo(function Filters({ @@ -116,16 +112,49 @@ export default memo(function Filters({ openInfoFeature, onClearOpenInfoFeature, travelTimeEntries, - travelTimeDataRanges, onTravelTimeAddEntry, onTravelTimeRemoveEntry, onTravelTimeSetDestination, onTravelTimeRangeChange, + onTravelTimeToggleBest, aiFilterLoading, aiFilterError, aiFilterNotes, onAiFilterSubmit, + isLicensed, + onUpgradeClick, + onResetTutorial, }: FiltersProps) { + const modeRestrictions = useMemo(() => { + const map: Record> = {}; + for (const f of features) { + if (f.modes && f.modes.length > 0) { + map[f.name] = new Set(f.modes as ListingType[]); + } + } + return map; + }, [features]); + + const linkedFeatures = useMemo(() => { + const pairs: [string, string][] = []; + const seen = new Set(); + for (const f of features) { + if (f.linked && !seen.has(f.name)) { + pairs.push([f.name, f.linked]); + seen.add(f.linked); + } + } + return pairs; + }, [features]); + + const isAllowed = useCallback( + (name: string, mode: ListingType) => { + const allowed = modeRestrictions[name]; + return !allowed || allowed.has(mode); + }, + [modeRestrictions] + ); + const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; if (!val || val.length === 0) return 'historical'; @@ -135,8 +164,8 @@ export default memo(function Filters({ }, [filters]); const availableFeatures = useMemo( - () => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)), - [features, enabledFeatures, activeListingType] + () => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)), + [features, enabledFeatures, activeListingType, isAllowed] ); const enabledFeatureList = useMemo( () => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'), @@ -145,16 +174,26 @@ export default memo(function Filters({ const handleListingSelect = useCallback( (type: ListingType) => { - if (type === activeListingType && !filters['Listing status']) return; for (const name of Object.keys(filters)) { - if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { + if (name === 'Listing status') continue; + if (isAllowed(name, type)) continue; + + // Check if this feature has a linked counterpart in the new mode + let swapped = false; + for (const [a, b] of linkedFeatures) { + const counterpart = name === a ? b : name === b ? a : null; + if (counterpart && isAllowed(counterpart, type)) { + onFilterChange(counterpart, filters[name] as [number, number]); + onRemoveFilter(name); + swapped = true; + break; + } + } + + if (!swapped) { onRemoveFilter(name); } } - if (type === 'historical' && !filters['Listing status']) { - onFilterChange('Listing status', ['Historical sale']); - return; - } const valueMap: Record = { historical: 'Historical sale', buy: 'For sale', @@ -162,7 +201,7 @@ export default memo(function Filters({ }; onFilterChange('Listing status', [valueMap[type]]); }, - [activeListingType, filters, onFilterChange, onRemoveFilter] + [filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures] ); const containerRef = useRef(null); @@ -205,7 +244,7 @@ export default memo(function Filters({
@@ -416,6 +478,8 @@ export default memo(function Filters({ onClearOpenInfoFeature={onClearOpenInfoFeature} travelTimeEntries={travelTimeEntries} onAddTravelTimeEntry={onTravelTimeAddEntry} + isLicensed={isLicensed} + onUpgradeClick={onUpgradeClick} />
@@ -423,59 +487,95 @@ export default memo(function Filters({ {showPhilosophy && ( setShowPhilosophy(false)}>
+

+ Start with your must-haves, then layer on nice-to-haves. + The map narrows down as you add filters — the areas that survive are your best matches. +

+

- Be intentional, not reactive + 1. Budget & property basics

- Your future home isn't a box of cereal you grab because it's on sale. - Don't let a seemingly good deal turn into lifelong regret. Instead of waiting - for listings to appear, define what you actually want and go find it. + Set your price range, minimum floor area, and property type. + If you need a lease over freehold (or vice versa), filter for that too. + This eliminates most of the map immediately.

- See the full picture + 2. Commute & transport

- Current listings show only a fraction of the market. There are too few to give you a - complete picture, yet too many to evaluate one by one. We aggregate millions of - historical sales so you can understand what's truly available in any area. + Add a travel time filter to your workplace — choose public transport or cycling + and set your maximum tolerable commute. You can also filter by + how many stations are within walking distance.

- Your priorities, your filters + 3. Safety & environment

- We all care about different things. Some want peace and quiet; others want to be - near the action. Use our filters to define exactly what matters to you and discover - postcodes that match. + Use the crime filters to cap serious or minor crime rates. + Check road noise levels if you're a light sleeper, and + environmental risk filters for ground stability concerns.

- Find the right place, not just the right listing + 4. Schools & education

- The best areas to live don't always have properties listed right now. We help - you identify where you should be looking, so when something does come up, - you're ready. + Filter by the number of Ofsted-rated Good or Outstanding primary and + secondary schools nearby. The education deprivation score captures + broader area-level attainment.

- Know what's possible + 5. Lifestyle & amenities

- We'd rather tell you upfront if your expectations are unrealistic than have you - spend months searching for something that doesn't exist. + Want restaurants, parks, or grocery shops within walking distance? + Filter by nearby amenity counts. Broadband speed filters help if + you work from home.

+ +
+

+ 6. Energy & running costs +

+

+ EPC ratings from A to G indicate energy efficiency. + Filter for better ratings to find homes with lower bills and + fewer upgrade headaches. +

+
+ +
+

+ Tip: if nothing survives your filters, relax one constraint at a time + to see which compromise unlocks the most options. +

+
+ + {onResetTutorial && ( + + )}
)} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index bb06df3..b9a99d8 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -1,5 +1,5 @@ -import { memo } from 'react'; -import type { FeatureFilters } from '../../types'; +import { memo, useMemo } from 'react'; +import type { FeatureFilters, FeatureMeta } from '../../types'; import { formatValue } from '../../lib/format'; interface HoverCardData { @@ -14,11 +14,17 @@ interface HoverCardProps { isPostcode: boolean; data: HoverCardData | null; filters: FeatureFilters; + features: FeatureMeta[]; } -export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) { +export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) { const activeFilterNames = Object.keys(filters); + const featureMap = useMemo( + () => new Map(features.map((f) => [f.name, f])), + [features] + ); + // Get key stats to show from local data (min_ values) const getDisplayStats = () => { if (!data) return []; @@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: // Show stats for active filters (up to 4) for (const name of activeFilterNames.slice(0, 4)) { const val = data[`avg_${name}`] ?? data[`min_${name}`]; - if (val != null && typeof val === 'number') { - results.push({ name, value: formatValue(val) }); + if (val == null || typeof val !== 'number') continue; + const meta = featureMap.get(name); + if (meta?.type === 'enum' && meta.values) { + const label = meta.values[Math.round(val)]; + if (label) results.push({ name, value: label }); + } else { + results.push({ name, value: formatValue(val, meta) }); } } diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 2a0679a..34790ea 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -170,6 +170,7 @@ export default memo(function Map({ data, postcodeData, usePostcodeView, + zoom: viewState.zoom, pois, viewFeature, colorRange, @@ -296,6 +297,7 @@ export default memo(function Map({ : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} + features={features} /> )} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 66aaba7..674afe0 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -27,6 +27,7 @@ import { type TravelTimeInitial, } from '../../hooks/useTravelTime'; import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api'; +import { trackEvent } from '../../lib/analytics'; import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { useLicense } from '../../hooks/useLicense'; import UpgradeModal from '../ui/UpgradeModal'; @@ -188,24 +189,6 @@ export default function MapPage({ const pois = usePOIData(mapData.bounds, selectedPOICategories); - const travelTimeDataRanges = useMemo((): globalThis.Map => { - const ranges = new globalThis.Map(); - for (let i = 0; i < travelTime.entries.length; i++) { - const entry = travelTime.entries[i]; - if (!entry.slug) continue; - const fieldName = `avg_${travelFieldKey(entry)}`; - const vals: number[] = []; - for (const item of mapData.data) { - const val = item[fieldName]; - if (typeof val === 'number' && !isNaN(val)) vals.push(val); - } - if (vals.length === 0) continue; - vals.sort((a, b) => a - b); - ranges.set(i, [vals[0], vals[vals.length - 1]]); - } - return ranges; - }, [travelTime.entries, mapData.data]); - useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); useEffect(() => { @@ -229,16 +212,21 @@ export default function MapPage({ const isPostcode = selection.selectedHexagon?.type === 'postcode'; if (isPostcode) { - // For postcodes, get centroid from postcodeData + // For postcodes, get centroid from postcodeData; postcode string is the selection id const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); if (!postcodeFeature?.properties.centroid) return null; const [lon, lat] = postcodeFeature.properties.centroid; - return { lat, lon, resolution: mapData.resolution }; + return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true }; } else { - // For hexagons, get lat/lon from hexagon data + // For hexagons, get lat/lon from hexagon data; central postcode comes from stats const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; - return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; + return { + lat: hex.lat as number, + lon: hex.lon as number, + resolution: mapData.resolution, + postcode: selection.areaStats?.central_postcode, + }; } }, [ selection.selectedHexagon?.id, @@ -246,6 +234,7 @@ export default function MapPage({ mapData.data, mapData.postcodeData, mapData.resolution, + selection.areaStats?.central_postcode, ]); const tutorial = useTutorial(initialLoading, isMobile); @@ -273,6 +262,7 @@ export default function MapPage({ link.download = 'perfect-postcode-export.xlsx'; link.click(); URL.revokeObjectURL(link.href); + trackEvent('Export'); }) .catch((err) => logNonAbortError('Export failed', err)) .finally(() => setExporting(false)); @@ -282,6 +272,10 @@ export default function MapPage({ onExportStateChange?.({ onExport: handleExport, exporting }); }, [handleExport, exporting, onExportStateChange]); + useEffect(() => { + if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown'); + }, [mapData.licenseRequired]); + const mobileLegendMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] @@ -395,15 +389,18 @@ export default function MapPage({ openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={travelTime.entries} - travelTimeDataRanges={travelTimeDataRanges} onTravelTimeAddEntry={travelTime.handleAddEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={travelTime.handleTimeRangeChange} + onTravelTimeToggleBest={travelTime.handleToggleBest} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} aiFilterNotes={aiFilters.notes} onAiFilterSubmit={handleAiFilterSubmit} + isLicensed={user?.subscription === 'licensed'} + onUpgradeClick={() => onNavigateTo('pricing')} + onResetTutorial={tutorial.resetTutorial} /> ); @@ -560,6 +557,7 @@ export default function MapPage({ callback={tutorial.handleCallback} styles={getTutorialStyles(theme)} disableScrolling + locale={{ last: 'Finish' }} />
setPoiPaneOpen((p) => !p)} - className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`} + className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`} > + Points of interest {/* Floating POI panel */} {poiPaneOpen && ( @@ -626,38 +625,40 @@ export default function MapPage({ )}
-
+ {selection.selectedHexagon && (
-
-
-
-
- selection.setRightPaneTab('area')} - /> - +
+
+
+
+ selection.setRightPaneTab('area')} + /> + +
-
- {selection.rightPaneTab === 'properties' - ? renderPropertiesPane() - : renderAreaPane()} +
+ {selection.rightPaneTab === 'properties' + ? renderPropertiesPane() + : renderAreaPane()} +
-
+ )} {mapData.licenseRequired && ( { const newSet = new Set(selectedCategories); - if (newSet.has(category)) { + const wasSelected = newSet.has(category); + if (wasSelected) { newSet.delete(category); } else { newSet.add(category); } + trackEvent('POI Toggle', { category, selected: String(!wasSelected) }); onCategoriesChange(newSet); }; const selectAll = () => { + trackEvent('POI Select All'); onCategoriesChange(new Set(allCategories)); }; const selectNone = () => { + trackEvent('POI Select None'); onCategoriesChange(new Set()); }; diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 424dd98..81df15a 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -5,21 +5,33 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; -import { RouteIcon } from '../ui/icons/RouteIcon'; +import { CarIcon } from '../ui/icons/CarIcon'; +import { BicycleIcon } from '../ui/icons/BicycleIcon'; +import { WalkingIcon } from '../ui/icons/WalkingIcon'; +import { TransitIcon } from '../ui/icons/TransitIcon'; import { formatFilterValue } from '../../lib/format'; import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime'; +import type { ComponentType } from 'react'; + +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; interface TravelTimeCardProps { mode: TransportMode; slug: string; label: string; timeRange: [number, number] | null; - dataRange: [number, number] | null; + useBest: boolean; isPinned: boolean; onTogglePin: () => void; onSetDestination: (slug: string, label: string) => void; onTimeRangeChange: (range: [number, number]) => void; + onToggleBest: () => void; onRemove: () => void; } @@ -28,11 +40,12 @@ export function TravelTimeCard({ slug, label, timeRange, - dataRange, + useBest, isPinned, onTogglePin, onSetDestination, onTimeRangeChange, + onToggleBest, onRemove, }: TravelTimeCardProps) { const search = useLocationSearch(mode); @@ -59,16 +72,18 @@ export function TravelTimeCard({ [onSetDestination, search.clear], ); - const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0; - const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; + const sliderMin = 0; + const sliderMax = 120; const displayRange = timeRange ?? [sliderMin, sliderMax]; + const ModeIcon = MODE_ICONS[mode]; + return (
{/* Header */}
- + Travel Time ({MODE_LABELS[mode]}) @@ -106,8 +121,26 @@ export function TravelTimeCard({ )}
+ {/* Best-case toggle — transit only, shown when destination is set */} + {slug && mode === 'transit' && ( + + )} + {/* Time range slider — only show when we have data */} - {slug && dataRange && ( + {slug && (
Max time diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 0e6fcc9..9c5733b 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { AuthUser } from '../../hooks/useAuth'; import { useLicense } from '../../hooks/useLicense'; +import { trackEvent } from '../../lib/analytics'; import { apiUrl } from '../../lib/api'; const FEATURES = [ @@ -59,14 +60,8 @@ export default function PricingPage({ }, []); useEffect(() => { - if (!pricing || !scrollRef.current || !activeCardRef.current) return; - if (currentTierIndex === 0) return; - const container = scrollRef.current; - const card = activeCardRef.current; - const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; - container.scrollLeft = Math.max(0, scrollLeft); - setScrolledLeft(container.scrollLeft > 0); - }, [pricing, currentTierIndex]); + trackEvent('Pricing View'); + }, []); useEffect(() => { fetch(apiUrl('pricing')) @@ -98,6 +93,16 @@ export default function PricingPage({ } } + useEffect(() => { + if (!pricing || !scrollRef.current || !activeCardRef.current) return; + if (currentTierIndex === 0) return; + const container = scrollRef.current; + const card = activeCardRef.current; + const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + container.scrollLeft = Math.max(0, scrollLeft); + setScrolledLeft(container.scrollLeft > 0); + }, [pricing, currentTierIndex]); + const ctaButton = isLicensed ? (
-
+

Early access pricing

- No subscriptions, no recurring fees. Pay once and get lifetime - access to every feature. The earlier you join, the less you pay. + Pay once, access forever. The earlier you join, the less you pay. +

+
+ +
+

+ Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, + £500 for a survey. Get the wrong area and you're stuck with a long + commute, bad schools, or a road you didn't know about. +

+

+ Less than your survey costs. Vastly more useful.

@@ -203,7 +218,7 @@ export default function PricingPage({
{scrolledLeft &&
}
-
+
{pricing.tiers.map((tier, i) => { const isCurrent = i === currentTierIndex; const isFilled = @@ -348,17 +363,6 @@ export default function PricingPage({ )}
-
-

- Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500. - Survey: £500. Moving costs: £1,000. And that's just the money. Get the - wrong area and you're stuck — with a long commute, bad schools, or a street - that looked fine on the listing photos but turns out to be on a motorway. -

-

- One payment. Lifetime access. Less than your survey costs and vastly more useful. -

-
); } diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 271bda2..7d98fe0 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; +import { trackEvent } from '../../lib/analytics'; type View = 'login' | 'register' | 'forgot'; @@ -30,6 +31,10 @@ export default function AuthModal({ const [password, setPassword] = useState(''); const [resetSent, setResetSent] = useState(false); + useEffect(() => { + trackEvent('Auth Modal Open', { tab: initialTab }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const switchView = useCallback( (newView: View) => { setView(newView); diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 3bcaa2c..5c4487f 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -1,5 +1,6 @@ import type { FeatureMeta } from '../../types'; import { InfoIcon } from './icons'; +import { getGroupIcon } from '../../lib/group-icons'; interface FeatureLabelProps { feature: FeatureMeta; @@ -15,11 +16,15 @@ export function FeatureLabel({ size = 'xs', }: FeatureLabelProps) { const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; + const GroupIcon = feature.group ? getGroupIcon(feature.group) : null; return (
+ {GroupIcon && ( + + )} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index e55ec2e..7c666f7 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; import { shortenUrl } from '../../lib/api'; +import { copyToClipboard } from '../../lib/clipboard'; import { DownloadIcon } from './icons/DownloadIcon'; import { BookmarkIcon } from './icons/BookmarkIcon'; import { LogoIcon } from './icons/LogoIcon'; @@ -63,42 +64,29 @@ export default function Header({ if (!isMobile) setMenuOpen(false); }, [isMobile]); - const copyToClipboard = useCallback((text: string) => { - const onSuccess = () => { + const doCopy = useCallback((text: string) => { + copyToClipboard(text, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } + }); }, []); const handleShare = useCallback(async () => { const params = window.location.search.replace(/^\?/, ''); if (!params) { - copyToClipboard(window.location.href); + doCopy(window.location.href); return; } setSharing(true); try { const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl); + doCopy(shortUrl); } catch { - copyToClipboard(window.location.href); + doCopy(window.location.href); } finally { setSharing(false); } - }, [copyToClipboard]); + }, [doCopy]); const tabClass = (page: Page) => `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ diff --git a/frontend/src/components/ui/SaveSearchModal.tsx b/frontend/src/components/ui/SaveSearchModal.tsx index c8da9e7..0fd7611 100644 --- a/frontend/src/components/ui/SaveSearchModal.tsx +++ b/frontend/src/components/ui/SaveSearchModal.tsx @@ -1,19 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; +import { CheckIcon } from './icons/CheckIcon'; import { CloseIcon } from './icons/CloseIcon'; import { SpinnerIcon } from './icons/SpinnerIcon'; export default function SaveSearchModal({ onClose, onSave, + onViewSearches, saving, error, }: { onClose: () => void; onSave: (name: string) => Promise; + onViewSearches: () => void; saving: boolean; error: string | null; }) { const [name, setName] = useState(''); + const [saved, setSaved] = useState(false); const handleSubmit = useCallback( async (e: React.FormEvent) => { @@ -21,12 +25,12 @@ export default function SaveSearchModal({ if (!name.trim() || saving) return; try { await onSave(name.trim()); - onClose(); + setSaved(true); } catch { // Error displayed in modal } }, - [name, saving, onSave, onClose] + [name, saving, onSave] ); useEffect(() => { @@ -45,7 +49,9 @@ export default function SaveSearchModal({ onClick={(e) => e.stopPropagation()} >
-

Save Search

+

+ {saved ? 'Search saved' : 'Save Search'} +

-
-
- - setName(e.target.value)} - className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" - placeholder="My search" - autoFocus - /> + {saved ? ( +
+
+ +

+ Your search has been saved successfully. +

+
+
+ + +
+ ) : ( + +
+ + setName(e.target.value)} + className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + placeholder="My search" + autoFocus + /> +
- {error &&

{error}

} + {error &&

{error}

} -
- - -
- +
+ + +
+ + )}
); diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx index 3d84025..04f3f68 100644 --- a/frontend/src/components/ui/UserMenu.tsx +++ b/frontend/src/components/ui/UserMenu.tsx @@ -38,11 +38,29 @@ export default function UserMenu({ {open && (
-

- {user.email} -

+
+

+ {user.email} +

+ + {user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'} + +
+ setOpen(false)} + className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded" + > + Account +