From 49f7ec2f5a8fc496f671d7439e3edd192d0fcfcf Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 14 Mar 2026 21:36:00 +0000 Subject: [PATCH] All changes --- CLAUDE.md | 6 +- Dockerfile | 11 +- README.md | 2 +- frontend/src/App.tsx | 70 +- .../src/components/account/AccountPage.tsx | 907 ++++++++++++------ frontend/src/components/home/HomePage.tsx | 2 +- frontend/src/components/home/ScrollStory.tsx | 4 +- frontend/src/components/invite/InvitePage.tsx | 193 +++- frontend/src/components/learn/LearnPage.tsx | 10 +- frontend/src/components/map/AiFilterInput.tsx | 11 +- frontend/src/components/map/AreaPane.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 12 +- frontend/src/components/map/Filters.tsx | 14 +- frontend/src/components/map/MapPage.tsx | 41 +- .../src/components/map/PropertiesPane.tsx | 68 +- .../src/components/map/TravelTimeCard.tsx | 53 +- .../src/components/pricing/PricingPage.tsx | 5 +- .../components/ui/CollapsibleGroupHeader.tsx | 4 +- .../src/components/ui/DestinationDropdown.tsx | 79 +- frontend/src/components/ui/Header.tsx | 55 +- frontend/src/components/ui/MobileMenu.tsx | 14 +- .../src/components/ui/PlaceSearchInput.tsx | 29 +- frontend/src/components/ui/UpgradeModal.tsx | 4 +- .../src/components/ui/icons/BookmarkIcon.tsx | 5 +- .../src/components/ui/icons/SparklesIcon.tsx | 13 + frontend/src/components/ui/icons/index.ts | 45 +- frontend/src/hooks/useDropdownPosition.ts | 28 + frontend/src/hooks/useMapData.ts | 1 - frontend/src/hooks/useSavedProperties.ts | 145 +++ frontend/src/hooks/useSavedSearches.ts | 29 +- frontend/src/hooks/useTravelTime.ts | 7 + frontend/src/index.html | 2 +- frontend/src/lib/consts.ts | 2 +- frontend/src/lib/feature-icons.tsx | 35 +- frontend/src/lib/url-state.ts | 7 + pipeline/download/broadband.py | 3 +- pipeline/download/places.py | 14 +- pipeline/transform/merge.py | 2 +- .../transform/postcode_boundaries/voronoi.py | 3 +- pipeline/transform/transform_geosure.py | 11 +- screenshot/src/cache.ts | 4 + screenshot/src/server.ts | 7 +- server-rs/src/consts.rs | 2 +- server-rs/src/data/property.rs | 34 - server-rs/src/features.rs | 4 +- server-rs/src/licensing.rs | 14 +- server-rs/src/main.rs | 4 +- server-rs/src/og_middleware.rs | 37 +- server-rs/src/pocketbase.rs | 158 ++- server-rs/src/routes.rs | 2 +- server-rs/src/routes/export.rs | 7 +- server-rs/src/routes/hexagon_stats.rs | 6 +- server-rs/src/routes/hexagons.rs | 18 +- server-rs/src/routes/invites.rs | 165 +++- server-rs/src/routes/pois.rs | 4 +- server-rs/src/routes/postcode_properties.rs | 7 +- server-rs/src/routes/postcode_stats.rs | 7 +- server-rs/src/routes/postcodes.rs | 18 +- server-rs/src/routes/properties.rs | 6 +- server-rs/src/routes/travel_time.rs | 10 +- 60 files changed, 1783 insertions(+), 679 deletions(-) create mode 100644 frontend/src/components/ui/icons/SparklesIcon.tsx create mode 100644 frontend/src/hooks/useDropdownPosition.ts create mode 100644 frontend/src/hooks/useSavedProperties.ts diff --git a/CLAUDE.md b/CLAUDE.md index 835947c..7b9d626 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,8 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist` React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. No state management library — pure React hooks. **Architecture:** -- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation (home/dashboard/data-sources/faq) +- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation. Page type is `'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'`. Auth-required pages (`account`, `saved`, `invites`) redirect to home with login modal when unauthenticated. `pageToPath()` / `pathToPage()` map between Page values and URL paths. +- `AccountPage.tsx` — Exports three separate page components: `SavedPage` (`/saved` — saved searches + saved properties with sub-tabs), `InvitesPage` (`/invites` — invite link generation + history), and `AccountPage` (default export, `/account` — email, subscription, newsletter, support). Note: `'invite'` (singular, `/invite/:code`) is the invite *redemption* flow — distinct from `'invites'` (plural, `/invites`) which is the invite *management* page. - `MapPage.tsx` — Dashboard layout: composes map + left/right panes, uses custom hooks for all logic - Custom hooks in `hooks/` encapsulate stateful logic: - `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation @@ -138,6 +139,8 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N - Viewport bounds computed via `getBoundsFromViewState()` in `map-utils.ts` — uses Web Mercator math with **TILE_SIZE=512** (MapLibre/deck.gl convention, NOT 256) - Properties pane uses feature names from API response (human-readable), not hardcoded field names - Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns +- **Nav links must be `` tags, not ` + +

{message}

+
+ + +
+ + + ); +} -function SavedSearchesContent({ +function formatPropertyPrice(data: SavedPropertyData): string | null { + if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`; + if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`; + if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`; + if (data.price) return `£${formatNumber(data.price)}`; + return null; +} + +function formatPropertyDetails(data: SavedPropertyData): string { + const parts: string[] = []; + if (data.propertySubType) parts.push(data.propertySubType); + else if (data.propertyType) parts.push(data.propertyType); + if (data.bedrooms) parts.push(`${data.bedrooms} bed`); + if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`); + if (data.energyRating) parts.push(`EPC ${data.energyRating}`); + return parts.join(' · '); +} + +function SavedSearchesTab({ searches, loading, onDelete, @@ -60,148 +134,400 @@ function SavedSearchesContent({ } }, [doCopy]); + if (loading) { + return ( +
+ +
+ ); + } + + if (searches.length === 0) { + return ( +
+ +

+ No saved searches yet +

+

+ Save your dashboard filters and view to quickly return to them later. +

+
+ ); + } + return ( <> - {loading ? ( -
- -
- ) : searches.length === 0 ? ( -
- -

- No saved searches yet -

-

- Save your dashboard filters and view to quickly return to them later. -

-
- ) : ( -
- {searches.map((search) => ( -
- {search.screenshotUrl ? ( - {search.name} - ) : ( -
- -
- )} +
+ {searches.map((search) => ( +
+ {search.screenshotUrl ? ( + {search.name} + ) : ( +
+ +
+ )} -
-

- {search.name} -

-

- {formatRelativeTime(search.created)} -

-

- {summarizeParams(search.params)} -

+
+

+ {search.name} +

+

+ {formatRelativeTime(search.created)} +

+

+ {summarizeParams(search.params)} +

-
- - - -
+
+ + +
- ))} -
- )} - - {/* Delete confirmation dialog */} - {deleteConfirmId && ( -
setDeleteConfirmId(null)} - > -
-
e.stopPropagation()} - > -
-

Delete search

- -
-

- Are you sure you want to delete this saved search? This cannot be undone. -

-
- - -
-
+ ))} +
+ + {deleteConfirmId && ( + setDeleteConfirmId(null)} + onConfirm={handleDeleteConfirm} + /> )} ); } -function SettingsContent({ - user, - onRefreshAuth, - onRequestVerification, +function SavedPropertiesTab({ + properties, + loading, + onDelete, + onOpen, }: { - user: AuthUser; - onRefreshAuth: () => Promise; - onRequestVerification: (email: string) => Promise; + properties: SavedProperty[]; + loading: boolean; + onDelete: (id: string) => Promise; + onOpen: (postcode: string) => void; }) { - const [newsletterSaving, setNewsletterSaving] = useState(false); - const [newsletterError, setNewsletterError] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); - // Verification state - const [verificationSending, setVerificationSending] = useState(false); - const [verificationSent, setVerificationSent] = useState(false); + const handleDeleteConfirm = useCallback(async () => { + if (!deleteConfirmId) return; + await onDelete(deleteConfirmId); + setDeleteConfirmId(null); + }, [deleteConfirmId, onDelete]); - // Invite state — keyed by invite type for admins (who can create both kinds) + if (loading) { + return ( +
+ +
+ ); + } + + if (properties.length === 0) { + return ( +
+ +

+ No saved properties yet +

+

+ Click the bookmark icon on any property in the dashboard to save it here. +

+
+ ); + } + + return ( + <> +
+ + {deleteConfirmId && ( + setDeleteConfirmId(null)} + onConfirm={handleDeleteConfirm} + /> + )} + + ); +} + +export function SavedPage({ + searches, + searchesLoading, + onDeleteSearch, + onOpenSearch, + savedProperties, + propertiesLoading, + onDeleteProperty, + onOpenProperty, +}: { + searches: SavedSearch[]; + searchesLoading: boolean; + onDeleteSearch: (id: string) => Promise; + onOpenSearch: (params: string) => void; + savedProperties: SavedProperty[]; + propertiesLoading: boolean; + onDeleteProperty: (id: string) => Promise; + onOpenProperty: (postcode: string) => void; +}) { + const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches'); + + const tabClass = (tab: string) => + `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ + activeTab === tab + ? 'border-teal-600 dark:border-teal-400 text-teal-600 dark:text-teal-400' + : 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300' + }`; + + return ( + +
+ + +
+ + {activeTab === 'searches' ? ( + + ) : ( + + )} +
+ ); +} + +interface InviteListItem { + code: string; + url: string; + invite_type: string; + used: boolean; + created: string; +} + +function InviteTable({ + invites, + loading, + title, +}: { + invites: InviteListItem[]; + loading: boolean; + title: string; +}) { + const [copiedCode, setCopiedCode] = useState(null); + + const handleCopy = (url: string, code: string) => { + copyToClipboard(url, () => { + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + }); + }; + + return ( +
+
+

{title}

+
+ {loading ? ( +
+ +
+ ) : invites.length === 0 ? ( +

+ No invites generated yet +

+ ) : ( + + + + + + + + + + {invites.map((inv) => ( + + + + + + + ))} + +
LinkStatusCreated +
+ {inv.code} + + + {inv.used ? 'Redeemed' : 'Pending'} + + + {formatRelativeTime(inv.created)} + + +
+ )} +
+ ); +} + +export function InvitesPage({ user }: { user: AuthUser }) { const [creatingInvite, setCreatingInvite] = useState>({}); const [inviteUrl, setInviteUrl] = useState>({}); const [inviteError, setInviteError] = useState>({}); const [inviteCopied, setInviteCopied] = useState>({}); + const [inviteHistory, setInviteHistory] = useState([]); + const [inviteHistoryLoading, setInviteHistoryLoading] = useState(false); + + const isLicensed = user.subscription === 'licensed' || user.isAdmin; + + const fetchInviteHistory = useCallback(async () => { + setInviteHistoryLoading(true); + try { + const res = await fetch(apiUrl('invites'), authHeaders()); + assertOk(res, 'Fetch invites'); + const data = await res.json(); + setInviteHistory(data.invites); + } catch { + // Silent — non-critical + } finally { + setInviteHistoryLoading(false); + } + }, []); + + useEffect(() => { + if (isLicensed) fetchInviteHistory(); + }, [isLicensed, fetchInviteHistory]); + const handleCreateInvite = async (type: string) => { setCreatingInvite((prev) => ({ ...prev, [type]: true })); setInviteError((prev) => { @@ -226,6 +552,7 @@ function SettingsContent({ assertOk(res, 'Create invite'); const data = await res.json(); setInviteUrl((prev) => ({ ...prev, [type]: data.url })); + fetchInviteHistory(); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to create invite'; setInviteError((prev) => ({ ...prev, [type]: msg })); @@ -243,108 +570,29 @@ function SettingsContent({ }); }; - const badgeColor = - user.subscription === 'licensed' - ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' - : 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'; + if (!isLicensed) { + return ( + +
+
+

+ Invite links are available for licensed users. +

+
+
+
+ ); + } - const isLicensed = user.subscription === 'licensed' || user.isAdmin; + const adminInvites = inviteHistory.filter((i) => i.invite_type === 'admin'); + const referralInvites = inviteHistory.filter((i) => i.invite_type === 'referral'); return ( -
-
- {/* Email */} -
-
-

Email

-

{user.email}

-
-
- {!user.verified && ( - - )} - - {user.verified ? 'Verified' : 'Unverified'} - -
-
- - {/* Subscription */} -
-
-

Subscription

- - {user.subscription === 'licensed' ? 'Licensed' : 'Free'} - -
-
- - {/* Newsletter */} -
- - {newsletterError && ( -

{newsletterError}

- )} -
- - {/* Invite friends */} - {isLicensed && - (user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => ( + +
+ {/* Generate invite links */} +
+ {(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (

{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'} @@ -384,23 +632,32 @@ function SettingsContent({ )}

))} +
+ {/* Invite history tables */} + {user.isAdmin && ( + <> + + + + )} + {!user.isAdmin && referralInvites.length > 0 && ( + + )}
- - {/* Support */} -
-

Need help? Email us at

- - support@perfect-postcode.co.uk - -

- We typically respond within 24 hours. -

-
-
+ ); } @@ -408,66 +665,130 @@ export default function AccountPage({ user, onRefreshAuth, onRequestVerification, - searches, - searchesLoading, - onDeleteSearch, - onOpenSearch, }: { user: AuthUser; onRefreshAuth: () => Promise; onRequestVerification: (email: string) => Promise; - searches: SavedSearch[]; - searchesLoading: boolean; - onDeleteSearch: (id: string) => Promise; - onOpenSearch: (params: string) => void; }) { - const [activeTab, setActiveTab] = useState(() => { - const hash = window.location.hash.slice(1); - return hash === 'settings' ? 'settings' : 'saved'; - }); + const [newsletterSaving, setNewsletterSaving] = useState(false); + const [newsletterError, setNewsletterError] = useState(null); - // Sync hash with tab - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.slice(1); - if (hash === 'settings') setActiveTab('settings'); - else setActiveTab('saved'); - }; - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, []); + const [verificationSending, setVerificationSending] = useState(false); + const [verificationSent, setVerificationSent] = useState(false); - const switchTab = (key: string) => { - const tab = key as AccountTab; - setActiveTab(tab); - window.history.replaceState( - window.history.state, - '', - `/account#${tab}` - ); - }; + const badgeColor = + user.subscription === 'licensed' + ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' + : 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'; return ( -
- -
-
- {activeTab === 'saved' ? ( - - ) : ( - - )} + +
+
+ {/* Email */} +
+
+

Email

+

{user.email}

+
+
+ {!user.verified && ( + + )} + + {user.verified ? 'Verified' : 'Unverified'} + +
+
+ + {/* Subscription */} +
+
+

Subscription

+ + {user.subscription === 'licensed' ? 'Licensed' : 'Free'} + +
+
+ + {/* Newsletter */} +
+ + {newsletterError && ( +

{newsletterError}

+ )} +
+
+ + {/* Support */} +
+

Need help? Email us at

+ + support@perfect-postcode.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 aec4acf..7294b60 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -79,7 +79,7 @@ export default function HomePage({ House hunting? Make your biggest investment your smartest move.

- So many options — choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that + So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.

diff --git a/frontend/src/components/home/ScrollStory.tsx b/frontend/src/components/home/ScrollStory.tsx index 29214cc..2cba49b 100644 --- a/frontend/src/components/home/ScrollStory.tsx +++ b/frontend/src/components/home/ScrollStory.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import MapComponent from '../map/Map'; -import { apiUrl, assertOk, authHeaders, isAbortError } from '../../lib/api'; +import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api'; import { formatValue } from '../../lib/format'; import { zoomToResolution } from '../../lib/map-utils'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; @@ -248,7 +248,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { }) .catch((err) => { if (!isAbortError(err)) { - console.error('Failed to fetch story hexagons:', err); + logNonAbortError('Failed to fetch story hexagons', err); setLoading(false); } }); diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx index 9474bee..f046f93 100644 --- a/frontend/src/components/invite/InvitePage.tsx +++ b/frontend/src/components/invite/InvitePage.tsx @@ -1,12 +1,15 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { apiUrl, authHeaders, assertOk } from '../../lib/api'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { CheckIcon } from '../ui/icons/CheckIcon'; +import HexCanvas from '../home/HexCanvas'; import type { AuthUser } from '../../hooks/useAuth'; interface InvitePageProps { code: string; user: AuthUser | null; + theme: 'light' | 'dark'; + screenshotMode?: boolean; onLoginClick: () => void; onRegisterClick: () => void; onLicenseGranted: () => void; @@ -16,11 +19,68 @@ interface InviteInfo { valid: boolean; invite_type: string; used: boolean; + invited_by: string | null; +} + +const CONFETTI_COLORS = ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + +function Confetti() { + const particles = useMemo( + () => + Array.from({ length: 40 }, (_, i) => ({ + id: i, + left: Math.random() * 100, + delay: Math.random() * 2, + duration: 2 + Math.random() * 2, + color: CONFETTI_COLORS[Math.floor(Math.random() * 6)], + size: 6 + Math.random() * 6, + isCircle: Math.random() > 0.5, + })), + [] + ); + + return ( +
+ {particles.map((p) => ( +
+ ))} + +
+ ); } export default function InvitePage({ code, user, + theme, + screenshotMode = false, onLoginClick, onRegisterClick, onLicenseGranted, @@ -32,6 +92,15 @@ export default function InvitePage({ const [redeemed, setRedeemed] = useState(false); const [pricePence, setPricePence] = useState(null); + const isDark = theme === 'dark'; + + // Signal screenshot readiness once loading completes + useEffect(() => { + if (screenshotMode && !loading) { + window.__screenshot_ready = true; + } + }, [screenshotMode, loading]); + useEffect(() => { let cancelled = false; (async () => { @@ -86,20 +155,75 @@ export default function InvitePage({ } }, [code, user, onLicenseGranted]); + if (screenshotMode && loading) { + return ( +
+ ); + } + + if (screenshotMode) { + const isAdminInvite = invite?.valid && !invite.used && invite.invite_type === 'admin'; + const isValid = invite?.valid && !invite.used; + return ( +
+
+
+

+ {isValid + ? isAdminInvite + ? "You\u2019re invited!" + : 'Special offer!' + : 'Perfect Postcode'} +

+

+ {isValid && invite.invited_by + ? isAdminInvite + ? `${invite.invited_by} has invited you to get free lifetime access.` + : `${invite.invited_by} has shared a 30% discount on lifetime access.` + : isValid + ? isAdminInvite + ? 'You have been invited to get free lifetime access.' + : 'A friend has shared a 30% discount on lifetime access.' + : 'Explore every neighbourhood in England'} +

+
+
+ {isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && ( +
+ + {`\u00A3${pricePence / 100}`} + + + {`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`} + + /once +
+ )} +

+ Property prices, energy ratings, crime stats, school ratings & more +

+
+
+
+ ); + } + if (loading) { return ( -
- +
+ +
); } if (error && !invite) { return ( -
-
-

Invalid invite

-

{error}

+
+ +
+

Invalid invite

+

{error}

); @@ -107,12 +231,13 @@ export default function InvitePage({ if (!invite?.valid || invite.used) { return ( -
-
-

+

+ +
+

{invite?.used ? 'Invite already used' : 'Invalid invite link'}

-

+

{invite?.used ? 'This invite link has already been redeemed.' : 'This invite link is invalid or has expired.'} @@ -124,15 +249,17 @@ export default function InvitePage({ if (redeemed) { return ( -

-
-
- +
+ + +
+
+
-

+

License activated!

-

+

You now have full access to Perfect Postcode.

@@ -143,25 +270,25 @@ export default function InvitePage({ const isAdminInvite = invite.invite_type === 'admin'; return ( -
-
+
+ + +

{isAdminInvite ? "You're invited!" : 'Special offer!'}

- {isAdminInvite - ? 'You have been invited to get free lifetime access.' - : 'A friend has shared a 30% discount on lifetime access.'} + {invite.invited_by + ? isAdminInvite + ? `${invite.invited_by} has invited you to get free lifetime access.` + : `${invite.invited_by} has shared a 30% discount on lifetime access.` + : isAdminInvite + ? 'You have been invited to get free lifetime access.' + : 'A friend has shared a 30% discount on lifetime access.'}

- {isAdminInvite && ( -
- Free - lifetime access -
- )} {!isAdminInvite && pricePence !== null && pricePence > 0 && (
@@ -174,7 +301,15 @@ export default function InvitePage({
)} - {user ? ( + {user?.subscription === 'licensed' ? ( +
+
+ +
+

You already have a license

+

Your account already has full access.

+
+ ) : user ? (
@@ -131,9 +131,9 @@ export default function FeatureBrowser({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - 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 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" + className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" > - + {group.features.length} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index eba3794..5b00bee 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -313,16 +313,16 @@ export default memo(function Filters({ name="Travel Time" expanded={!collapsedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - 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 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" + className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" > - + {travelTimeEntries.length} {!collapsedGroups.has('Travel Time') && (
{travelTimeEntries.map((entry, index) => ( -
+
toggleGroup(group.name)} - 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 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" + className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" > - + {group.features.length} @@ -373,7 +373,7 @@ export default memo(function Filters({
@@ -430,7 +430,7 @@ export default memo(function Filters({
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 2b1aa90..41791a8 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types'; +import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types'; import type { SearchedLocation } from './LocationSearch'; import type { Page } from '../ui/Header'; import Map from './Map'; @@ -56,9 +56,14 @@ interface MapPageProps { ogMode?: boolean; isMobile?: boolean; initialTravelTime?: TravelTimeInitial; + initialPostcode?: string; user?: { id: string; subscription: string } | null; onLoginClick?: () => void; onRegisterClick?: () => void; + onSaveProperty?: (property: Property) => void; + onUnsaveProperty?: (id: string) => void; + isPropertySaved?: (address?: string, postcode?: string) => boolean; + getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined; } export default function MapPage({ @@ -78,9 +83,14 @@ export default function MapPage({ ogMode, isMobile = false, initialTravelTime, + initialPostcode, user, onLoginClick, onRegisterClick, + onSaveProperty, + onUnsaveProperty, + isPropertySaved, + getSavedPropertyId, }: MapPageProps) { const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); @@ -202,6 +212,31 @@ export default function MapPage({ selection.setRightPaneTab(initialTab); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Navigate to a specific postcode on mount (e.g. from saved properties) + useEffect(() => { + if (!initialPostcode) return; + // Strip the `pc` param from the URL so it doesn't persist + const params = new URLSearchParams(window.location.search); + params.delete('pc'); + const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard'; + window.history.replaceState(window.history.state, '', newUrl); + + // Fetch postcode geometry and fly to it + fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders()) + .then((res) => { + if (!res.ok) throw new Error('Postcode not found'); + return res.json(); + }) + .then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => { + mapFlyToRef.current?.(data.latitude, data.longitude, 16); + selection.handleLocationSearch(data.postcode, data.geometry); + if (isMobile) setMobileDrawerOpen(true); + }) + .catch(() => { + // Silently fail — postcode might not exist + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Prevent browser back/forward navigation from horizontal trackpad swipes useEffect(() => { const handleWheel = (e: WheelEvent) => { @@ -381,6 +416,10 @@ export default function MapPage({ loading={selection.loadingProperties} hexagonId={selection.selectedHexagon?.id || null} onLoadMore={selection.handleLoadMoreProperties} + onSaveProperty={onSaveProperty} + onUnsaveProperty={onUnsaveProperty} + isPropertySaved={isPropertySaved} + getSavedPropertyId={getSavedPropertyId} /> ); diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index c97bf0b..005157e 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { Property } from '../../types'; import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format'; import { getNum } from '../../lib/property-fields'; @@ -6,6 +6,7 @@ import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; import { EmptyState } from '../ui/EmptyState'; import { InfoIcon } from '../ui/icons'; +import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; interface PropertiesPaneProps { properties: Property[]; @@ -14,6 +15,10 @@ interface PropertiesPaneProps { hexagonId: string | null; onLoadMore: () => void; onNavigateToSource?: (slug: string) => void; + onSaveProperty?: (property: Property) => void; + onUnsaveProperty?: (id: string) => void; + isPropertySaved?: (address?: string, postcode?: string) => boolean; + getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined; } export function PropertiesPane({ @@ -23,6 +28,10 @@ export function PropertiesPane({ hexagonId, onLoadMore, onNavigateToSource, + onSaveProperty, + onUnsaveProperty, + isPropertySaved, + getSavedPropertyId, }: PropertiesPaneProps) { const [search, setSearch] = useState(''); const [showInfo, setShowInfo] = useState(false); @@ -70,7 +79,7 @@ export function PropertiesPane({

Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy - ratings, construction age, and tenure from EPC surveys, plus the most recent sale price + ratings, construction year, and tenure from EPC surveys, plus the most recent sale price from the Land Registry.

@@ -91,7 +100,14 @@ export function PropertiesPane({ ) : ( <> {filtered.map((property, idx) => ( - + ))} {properties.length < total && ( + )}
{property.property_sub_type && ( diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index c5fe163..32b1a7b 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; import { InfoIcon } from '../ui/icons/InfoIcon'; -import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { CarIcon } from '../ui/icons/CarIcon'; import { BicycleIcon } from '../ui/icons/BicycleIcon'; import { WalkingIcon } from '../ui/icons/WalkingIcon'; @@ -52,6 +51,7 @@ export function TravelTimeCard({ onRemove, }: TravelTimeCardProps) { const { destinations, loading: destinationsLoading } = useTravelDestinations(mode); + const [showInfo, setShowInfo] = useState(false); const [showBestInfo, setShowBestInfo] = useState(false); const handleDestinationSelect = useCallback( @@ -78,6 +78,9 @@ export function TravelTimeCard({
+ setShowInfo(true)} title="Feature info"> + + {slug && ( @@ -90,28 +93,14 @@ export function TravelTimeCard({
{/* Destination */} - {slug && label ? ( -
- - - {label} - - -
- ) : ( - - )} + onSetDestination('', '')} + placeholder="Select destination..." + /> {/* Best-case toggle — transit only, shown when destination is set */} {slug && mode === 'transit' && ( @@ -123,10 +112,26 @@ export function TravelTimeCard({
)} + {showInfo && ( + setShowInfo(false)}> +

+ Shows how long it takes to reach the selected destination from each area + {mode === 'transit' + ? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.' + : mode === 'car' + ? ' by car, based on typical road speeds and the road network.' + : mode === 'bicycle' + ? ' by bicycle, using cycle-friendly routes.' + : ' on foot, using pedestrian paths and pavements.'} + {' '}Use the slider to filter areas within your preferred commute time. +

+
+ )} + {showBestInfo && ( setShowBestInfo(false)}>

- Uses the 5th percentile travel time — the fastest realistic journey + Uses the 5th percentile travel time - the fastest realistic journey if you time your departure to catch optimal connections. The default uses the{' '} median, representing a typical journey regardless of when you leave.

diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 9c5733b..4054991 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 { logNonAbortError } from '../../lib/api'; import { trackEvent } from '../../lib/analytics'; import { apiUrl } from '../../lib/api'; @@ -70,7 +71,7 @@ export default function PricingPage({ return res.json(); }) .then(setPricing) - .catch((err) => console.error('Failed to load pricing:', err)) + .catch((err) => logNonAbortError('Failed to load pricing', err)) .finally(() => setLoading(false)); }, []); @@ -123,7 +124,7 @@ export default function PricingPage({ ? 'Redirecting...' : isFree ? 'Claim free access' - : `Get started — ${formatPrice(currentPrice)}`} + : `Get started - ${formatPrice(currentPrice)}`} ) : ( ); diff --git a/frontend/src/components/ui/DestinationDropdown.tsx b/frontend/src/components/ui/DestinationDropdown.tsx index d237acf..58fb2d9 100644 --- a/frontend/src/components/ui/DestinationDropdown.tsx +++ b/frontend/src/components/ui/DestinationDropdown.tsx @@ -3,18 +3,21 @@ import { useRef, useEffect, useCallback, - useLayoutEffect, useMemo, } from 'react'; import { createPortal } from 'react-dom'; import type { Destination } from '../../hooks/useTravelDestinations'; +import { useDropdownPosition } from '../../hooks/useDropdownPosition'; import { MapPinIcon } from './icons/MapPinIcon'; import { ChevronIcon } from './icons/ChevronIcon'; +import { CloseIcon } from './icons/CloseIcon'; interface DestinationDropdownProps { destinations: Destination[]; loading: boolean; onSelect: (slug: string, label: string) => void; + onClear?: () => void; + value?: string; placeholder?: string; } @@ -22,19 +25,18 @@ export function DestinationDropdown({ destinations, loading, onSelect, + onClear, + value, placeholder = 'Select destination...', }: DestinationDropdownProps) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(''); const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef(null); + const dropdownRef = useRef(null); const inputRef = useRef(null); const listRef = useRef(null); - const [pos, setPos] = useState<{ - top: number; - left: number; - width: number; - } | null>(null); + const pos = useDropdownPosition(containerRef, open); const filtered = useMemo(() => { if (!filter) return destinations; @@ -46,31 +48,14 @@ export function DestinationDropdown({ ); }, [destinations, filter]); - // Position the dropdown portal - const updatePos = useCallback(() => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); - }, []); - - useLayoutEffect(() => { - if (!open) return; - updatePos(); - window.addEventListener('scroll', updatePos, true); - window.addEventListener('resize', updatePos); - return () => { - window.removeEventListener('scroll', updatePos, true); - window.removeEventListener('resize', updatePos); - }; - }, [open, updatePos]); - // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(e.target as Node) && + !dropdownRef.current?.contains(e.target as Node) ) { setOpen(false); setFilter(''); @@ -129,6 +114,7 @@ export function DestinationDropdown({ const dropdown = open && (
- + {value && onClear ? ( + ) : ( - + )} - {placeholder} - - +
{open && createPortal(dropdown, document.body)}
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 7c666f7..0930c1d 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -14,7 +14,18 @@ import { SpinnerIcon } from './icons/SpinnerIcon'; import UserMenu from './UserMenu'; import MobileMenu from './MobileMenu'; -export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite'; +export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'; + +export const PAGE_PATHS: Record = { + home: '/', + dashboard: '/dashboard', + learn: '/learn', + pricing: '/pricing', + saved: '/saved', + invites: '/invites', + account: '/account', + invite: '/invite', +}; export default function Header({ activePage, @@ -88,6 +99,12 @@ export default function Header({ } }, [doCopy]); + const navLink = (page: Page, e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + onPageChange(page); + }; + const tabClass = (page: Page) => `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ activePage === page @@ -99,35 +116,41 @@ export default function Header({
{/* Left: Logo + nav */}
- + {/* Desktop nav */} {!isMobile && ( )} diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 42652dd..ddbf7d4 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -1,4 +1,5 @@ import type { Page } from './Header'; +import { PAGE_PATHS } from './Header'; import type { AuthUser } from '../../hooks/useAuth'; import { DownloadIcon } from './icons/DownloadIcon'; import { BookmarkIcon } from './icons/BookmarkIcon'; @@ -45,20 +46,23 @@ export default function MobileMenu({ copied, }: MobileMenuProps) { const mobileNavItem = (page: Page, label: string) => ( - + ); return ( @@ -82,6 +86,8 @@ export default function MobileMenu({ {mobileNavItem('dashboard', 'Dashboard')} {mobileNavItem('learn', 'Learn')} {user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')} + {user && mobileNavItem('saved', 'Saved')} + {user && mobileNavItem('invites', 'Invite')} {user && mobileNavItem('account', 'Account')} {/* Dashboard actions */} diff --git a/frontend/src/components/ui/PlaceSearchInput.tsx b/frontend/src/components/ui/PlaceSearchInput.tsx index 9fbc4d0..366df2f 100644 --- a/frontend/src/components/ui/PlaceSearchInput.tsx +++ b/frontend/src/components/ui/PlaceSearchInput.tsx @@ -1,7 +1,8 @@ -import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react'; +import { useRef } from 'react'; import { createPortal } from 'react-dom'; import type React from 'react'; import type { SearchResult } from '../../hooks/useLocationSearch'; +import { useDropdownPosition } from '../../hooks/useDropdownPosition'; import { SearchIcon } from './icons/SearchIcon'; import { MapPinIcon } from './icons/MapPinIcon'; @@ -31,32 +32,6 @@ interface PlaceSearchInputProps { portal?: boolean; } -function useDropdownPosition( - anchorRef: React.RefObject, - open: boolean, -) { - const [pos, setPos] = useStateR<{ top: number; left: number; width: number } | null>(null); - - const update = useCallback(() => { - if (!anchorRef.current) return; - const rect = anchorRef.current.getBoundingClientRect(); - setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); - }, [anchorRef]); - - useLayoutEffect(() => { - if (!open) return; - update(); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return () => { - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; - }, [open, update]); - - return pos; -} - export function PlaceSearchInput({ search, onSelect, diff --git a/frontend/src/components/ui/UpgradeModal.tsx b/frontend/src/components/ui/UpgradeModal.tsx index 88a034b..52ec44a 100644 --- a/frontend/src/components/ui/UpgradeModal.tsx +++ b/frontend/src/components/ui/UpgradeModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { CloseIcon } from './icons/CloseIcon'; import { SpinnerIcon } from './icons/SpinnerIcon'; -import { apiUrl } from '../../lib/api'; +import { apiUrl, logNonAbortError } from '../../lib/api'; interface UpgradeModalProps { isLoggedIn: boolean; @@ -28,7 +28,7 @@ export default function UpgradeModal({ .then((data) => { if (data) setPricePence(data.current_price_pence); }) - .catch(() => {}); + .catch((err) => logNonAbortError('Failed to fetch pricing', err)); }, []); const priceLabel = diff --git a/frontend/src/components/ui/icons/BookmarkIcon.tsx b/frontend/src/components/ui/icons/BookmarkIcon.tsx index 0503d0f..7e49d23 100644 --- a/frontend/src/components/ui/icons/BookmarkIcon.tsx +++ b/frontend/src/components/ui/icons/BookmarkIcon.tsx @@ -1,13 +1,14 @@ interface IconProps { className?: string; + filled?: boolean; } -export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) { +export function BookmarkIcon({ className = 'w-3.5 h-3.5', filled = false }: IconProps) { return ( diff --git a/frontend/src/components/ui/icons/SparklesIcon.tsx b/frontend/src/components/ui/icons/SparklesIcon.tsx new file mode 100644 index 0000000..1e7c0e9 --- /dev/null +++ b/frontend/src/components/ui/icons/SparklesIcon.tsx @@ -0,0 +1,13 @@ +interface IconProps { + className?: string; +} + +export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index f968920..ce2fa2d 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -1,21 +1,34 @@ -export { CloseIcon } from './CloseIcon'; -export { InfoIcon } from './InfoIcon'; -export { EyeIcon } from './EyeIcon'; -export { PlusIcon } from './PlusIcon'; -export { ChevronIcon } from './ChevronIcon'; -export { FilterIcon } from './FilterIcon'; -export { LightbulbIcon } from './LightbulbIcon'; -export { MenuIcon } from './MenuIcon'; -export { RouteIcon } from './RouteIcon'; -export { CarIcon } from './CarIcon'; export { BicycleIcon } from './BicycleIcon'; -export { WalkingIcon } from './WalkingIcon'; -export { TransitIcon } from './TransitIcon'; -export { HouseIcon } from './HouseIcon'; -export { GraduationCapIcon } from './GraduationCapIcon'; +export { BookmarkIcon } from './BookmarkIcon'; +export { CarIcon } from './CarIcon'; export { ChartBarIcon } from './ChartBarIcon'; +export { CheckIcon } from './CheckIcon'; +export { ChevronIcon } from './ChevronIcon'; +export { ClipboardIcon } from './ClipboardIcon'; +export { CloseIcon } from './CloseIcon'; +export { DownloadIcon } from './DownloadIcon'; +export { EyeIcon } from './EyeIcon'; +export { FilterIcon } from './FilterIcon'; +export { GoogleIcon } from './GoogleIcon'; +export { GraduationCapIcon } from './GraduationCapIcon'; +export { HouseIcon } from './HouseIcon'; +export { InfoIcon } from './InfoIcon'; +export { LightbulbIcon } from './LightbulbIcon'; +export { LogoIcon } from './LogoIcon'; +export { MapPinIcon } from './MapPinIcon'; +export { MenuIcon } from './MenuIcon'; +export { MoonIcon } from './MoonIcon'; +export { PlusIcon } from './PlusIcon'; +export { RouteIcon } from './RouteIcon'; +export { SearchIcon } from './SearchIcon'; export { ShieldIcon } from './ShieldIcon'; -export { UsersIcon } from './UsersIcon'; export { ShoppingBagIcon } from './ShoppingBagIcon'; -export { TreeIcon } from './TreeIcon'; +export { SparklesIcon } from './SparklesIcon'; +export { SpinnerIcon } from './SpinnerIcon'; +export { SunIcon } from './SunIcon'; export { TagIcon } from './TagIcon'; +export { TrashIcon } from './TrashIcon'; +export { TransitIcon } from './TransitIcon'; +export { TreeIcon } from './TreeIcon'; +export { UsersIcon } from './UsersIcon'; +export { WalkingIcon } from './WalkingIcon'; diff --git a/frontend/src/hooks/useDropdownPosition.ts b/frontend/src/hooks/useDropdownPosition.ts new file mode 100644 index 0000000..baa95c2 --- /dev/null +++ b/frontend/src/hooks/useDropdownPosition.ts @@ -0,0 +1,28 @@ +import { useCallback, useLayoutEffect, useState } from 'react'; +import type React from 'react'; + +export function useDropdownPosition( + anchorRef: React.RefObject, + open: boolean, +) { + const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); + + const update = useCallback(() => { + if (!anchorRef.current) return; + const rect = anchorRef.current.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + }, [anchorRef]); + + useLayoutEffect(() => { + if (!open) return; + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [open, update]); + + return pos; +} diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index c2f46c0..17e7fa2 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -340,7 +340,6 @@ export function useMapData({ return { data, - rawData, postcodeData: effectivePostcodeData, resolution, bounds, diff --git a/frontend/src/hooks/useSavedProperties.ts b/frontend/src/hooks/useSavedProperties.ts new file mode 100644 index 0000000..9137ae4 --- /dev/null +++ b/frontend/src/hooks/useSavedProperties.ts @@ -0,0 +1,145 @@ +import { useState, useCallback, useMemo } from 'react'; +import pb from '../lib/pocketbase'; +import { trackEvent } from '../lib/analytics'; +import type { Property } from '../types'; +import { getNum } from '../lib/property-fields'; + +export interface SavedPropertyData { + propertyType?: string; + propertySubType?: string; + builtForm?: string; + duration?: string; + energyRating?: string; + price?: number; + estimatedPrice?: number; + askingPrice?: number; + askingRent?: number; + bedrooms?: number; + floorArea?: number; + listingUrl?: string; +} + +export interface SavedProperty { + id: string; + address: string; + postcode: string; + data: SavedPropertyData; + created: string; +} + +export function useSavedProperties(userId: string | null) { + const [properties, setProperties] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchProperties = useCallback(async () => { + if (!userId) return; + setLoading(true); + setError(null); + try { + const records = await pb.collection('saved_properties').getFullList({ + sort: '-created', + filter: `user = "${userId}"`, + }); + setProperties( + records.map((r) => { + const raw = r as Record; + let data: SavedPropertyData = {}; + try { + data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {}; + } catch { + // Invalid JSON — use empty data + } + return { + id: r.id, + address: raw.address as string, + postcode: raw.postcode as string, + data, + created: r.created, + }; + }) + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load saved properties'); + } finally { + setLoading(false); + } + }, [userId]); + + const saveProperty = useCallback( + async (property: Property) => { + if (!userId) return; + setError(null); + try { + const data: SavedPropertyData = { + propertyType: property.property_type, + propertySubType: property.property_sub_type, + builtForm: property.built_form, + duration: property.duration, + energyRating: property.current_energy_rating, + price: getNum(property, 'Last known price'), + estimatedPrice: getNum(property, 'Estimated current price'), + askingPrice: getNum(property, 'Asking price'), + askingRent: getNum(property, 'Asking rent (monthly)'), + bedrooms: getNum(property, 'Bedrooms'), + floorArea: getNum(property, 'Total floor area (sqm)'), + listingUrl: property.listing_url || undefined, + }; + + await pb.collection('saved_properties').create({ + user: userId, + address: property.address || 'Unknown', + postcode: property.postcode || '', + data: JSON.stringify(data), + }); + trackEvent('Property Save'); + await fetchProperties(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to save property'; + setError(msg); + } + }, + [userId, fetchProperties] + ); + + const deleteProperty = useCallback(async (id: string) => { + setError(null); + try { + await pb.collection('saved_properties').delete(id); + trackEvent('Property Delete'); + setProperties((prev) => prev.filter((p) => p.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete property'); + } + }, []); + + const savedPropertyKeys = useMemo( + () => new Set(properties.map((p) => `${p.address}|${p.postcode}`)), + [properties] + ); + + const isPropertySaved = useCallback( + (address?: string, postcode?: string) => + savedPropertyKeys.has(`${address || ''}|${postcode || ''}`), + [savedPropertyKeys] + ); + + const getSavedPropertyId = useCallback( + (address?: string, postcode?: string) => { + const key = `${address || ''}|${postcode || ''}`; + return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id; + }, + [properties] + ); + + return { + properties, + loading, + error, + fetchProperties, + saveProperty, + deleteProperty, + isPropertySaved, + getSavedPropertyId, + }; +} diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts index 5c05361..56ec409 100644 --- a/frontend/src/hooks/useSavedSearches.ts +++ b/frontend/src/hooks/useSavedSearches.ts @@ -52,23 +52,32 @@ export function useSavedSearches(userId: string | null) { try { const params = window.location.search.replace(/^\?/, ''); - // Capture a screenshot via the screenshot endpoint - const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params)); - const screenshotRes = await fetch(screenshotUrl, authHeaders()); - if (!screenshotRes.ok) { - throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`); - } - const screenshotBlob = await screenshotRes.blob(); - + // Create record immediately without screenshot const formData = new FormData(); formData.append('user', userId); formData.append('name', name); formData.append('params', params); - formData.append('screenshot', screenshotBlob, 'screenshot.png'); - await pb.collection('saved_searches').create(formData); + const record = await pb.collection('saved_searches').create(formData); trackEvent('Search Save'); await fetchSearches(); + + // Capture screenshot in background and attach it to the record + const screenshotParams = new URLSearchParams(params); + const screenshotUrl = apiUrl('screenshot', screenshotParams); + fetch(screenshotUrl, authHeaders()) + .then((res) => { + if (!res.ok) throw new Error(`Screenshot ${res.status}`); + return res.blob(); + }) + .then((blob) => { + const patch = new FormData(); + patch.append('screenshot', blob, 'screenshot.png'); + return pb.collection('saved_searches').update(record.id, patch); + }) + .catch((err) => { + console.warn('Background screenshot failed:', err); + }); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to save search'; setError(msg); diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index 2c74f58..e448942 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -11,6 +11,13 @@ export const MODE_LABELS: Record = { transit: 'Transit', }; +export const MODE_DESCRIPTIONS: Record = { + car: 'Drive time via the fastest road route', + bicycle: 'Cycling time using bike-friendly routes', + walking: 'Walking time along pedestrian paths and pavements', + transit: 'Journey time by train, tube, and bus', +}; + export interface TravelTimeEntry { mode: TransportMode; slug: string; diff --git a/frontend/src/index.html b/frontend/src/index.html index 6e75ff7..92e03f3 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -6,7 +6,7 @@ - Perfect Postcode — Every neighbourhood in England + Perfect Postcode - Every neighbourhood in England