good stuff

This commit is contained in:
Andras Schmelczer 2026-03-15 21:10:54 +00:00
parent ea8389ef40
commit f4de0eeb9f
39 changed files with 5165 additions and 348 deletions

View file

@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /api/
Disallow: /metrics
Disallow: /health
Disallow: /pb/
Disallow: /s/
Sitemap: https://perfect-postcode.co.uk/sitemap.xml

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://perfect-postcode.co.uk/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/dashboard</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/learn</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/pricing</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

View file

@ -18,6 +18,7 @@ import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
import { useTelemetry } from './hooks/useTelemetry';
import { useSavedSearches } from './hooks/useSavedSearches';
import { useSavedProperties } from './hooks/useSavedProperties';
@ -107,6 +108,7 @@ export default function App() {
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
useTelemetry();
const {
user,
loading: authLoading,
@ -337,12 +339,14 @@ export default function App() {
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
savedProperties={savedProperties.properties}
propertiesLoading={savedProperties.loading}
onDeleteProperty={savedProperties.deleteProperty}
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
onOpenProperty={(postcode) => {
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
}}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
@ -71,6 +71,63 @@ function DeleteDialog({
);
}
function NotesInput({
value,
onSave,
}: {
value: string;
onSave: (notes: string) => void;
}) {
const [text, setText] = useState(value);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Sync from parent when value changes externally
useEffect(() => {
setText(value);
}, [value]);
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);
// Resize on mount and when text changes
useEffect(() => {
autoResize();
}, [text, autoResize]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
setText(newText);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onSave(newText), 600);
},
[onSave]
);
// Save immediately on blur
const handleBlur = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (text !== value) onSave(text);
}, [text, value, onSave]);
return (
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jot down your thoughts..."
rows={1}
className="w-full resize-none overflow-hidden rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 px-3 py-1.5 text-sm text-warm-700 dark:text-warm-300 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
/>
);
}
function formatPropertyPrice(data: SavedPropertyData): string | null {
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
@ -93,11 +150,13 @@ function SavedSearchesTab({
searches,
loading,
onDelete,
onUpdateNotes,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -181,10 +240,17 @@ function SavedSearchesTab({
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
{summarizeParams(search.params)}
</p>
<div className="mb-3">
<NotesInput
value={search.notes}
onSave={(notes) => onUpdateNotes(search.id, notes)}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
@ -234,11 +300,13 @@ function SavedPropertiesTab({
properties,
loading,
onDelete,
onUpdateNotes,
onOpen,
}: {
properties: SavedProperty[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onOpen: (postcode: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -294,10 +362,17 @@ function SavedPropertiesTab({
{details && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
)}
<p className="text-xs text-warm-400 dark:text-warm-500 mb-3">
<p className="text-xs text-warm-400 dark:text-warm-500 mb-2">
{formatRelativeTime(prop.created)}
</p>
<div className="mb-3">
<NotesInput
value={prop.notes}
onSave={(notes) => onUpdateNotes(prop.id, notes)}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => onOpen(prop.postcode)}
@ -344,19 +419,23 @@ export function SavedPage({
searches,
searchesLoading,
onDeleteSearch,
onUpdateSearchNotes,
onOpenSearch,
savedProperties,
propertiesLoading,
onDeleteProperty,
onUpdatePropertyNotes,
onOpenProperty,
}: {
searches: SavedSearch[];
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
onDeleteProperty: (id: string) => Promise<void>;
onUpdatePropertyNotes: (id: string, notes: string) => void;
onOpenProperty: (postcode: string) => void;
}) {
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
@ -396,6 +475,7 @@ export function SavedPage({
searches={searches}
loading={searchesLoading}
onDelete={onDeleteSearch}
onUpdateNotes={onUpdateSearchNotes}
onOpen={onOpenSearch}
/>
) : (
@ -403,6 +483,7 @@ export function SavedPage({
properties={savedProperties}
loading={propertiesLoading}
onDelete={onDeleteProperty}
onUpdateNotes={onUpdatePropertyNotes}
onOpen={onOpenProperty}
/>
)}

View file

@ -167,15 +167,15 @@ export default function InvitePage({
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900">
<div className="w-[90%] bg-white dark:bg-warm-800 rounded-3xl border border-warm-200 dark:border-warm-700 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-20 py-16 text-center">
<h2 className="text-[144px] leading-tight font-bold text-white mb-6">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-16 py-10 text-center">
<h2 className="text-7xl leading-tight font-bold text-white mb-3">
{isValid
? isAdminInvite
? 'You\u2019re invited!'
: 'Special offer!'
: 'Perfect Postcode'}
</h2>
<p className="text-warm-300 text-6xl leading-snug">
<p className="text-warm-300 text-3xl leading-snug">
{isValid && invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
@ -187,19 +187,19 @@ export default function InvitePage({
: 'Explore every neighbourhood in England'}
</p>
</div>
<div className="px-20 py-14 text-center">
<div className="px-16 py-8 text-center">
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="mb-8">
<span className="text-warm-400 dark:text-warm-500 line-through text-8xl mr-6">
<div className="mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-5xl mr-4">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-[192px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
<span className="text-[96px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-4 text-6xl">/once</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">/once</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-6xl">
<p className="text-warm-600 dark:text-warm-400 text-3xl">
Property prices, energy ratings, crime stats, school ratings &amp; more
</p>
</div>

View file

@ -132,101 +132,176 @@ interface FAQItem {
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
interface FAQSection {
title: string;
items: FAQItem[];
}
const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Are the prices shown current market values?',
answer:
'No. The prices shown are the last known sale price recorded by HM Land Registry, which is the price the property actually sold for. A property last sold in 2005 will show its 2005 price. These are not valuations or estimates of current market value. You can use the "Last known price" filter to focus on properties sold within a recent date range, and compare against the median rental prices for the local authority.',
title: 'Finding Your Area',
items: [
{
question: "I don't even know which areas to look at \u2014 can this help with that?",
answer:
"That's exactly what it's for. Set your filters (budget, commute time, low crime, good schools \u2014 whatever matters) and the map lights up to show you where ticks every box. No more Googling \"best areas to live near Manchester\" at 1am.",
},
{
question: "I'm moving somewhere I've never been \u2014 how do I even start?",
answer:
"Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes \u2014 it's like having a local's knowledge of every neighbourhood in England.",
},
{
question: 'How do I find areas that tick all my boxes at once?',
answer:
'Stack multiple filters \u2014 say, crime below average, good schools, and commute under 40 minutes \u2014 then colour the map by price to spot the affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.',
},
],
},
{
question: 'What is the "Estimated current price" and how is it calculated?',
answer:
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time - this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records - such as extensions or renovations - also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
title: 'Commute & Travel',
items: [
{
question: 'Can I see how long my commute would actually be from different areas?',
answer:
"Set your workplace as a destination and we'll colour every postcode by journey time \u2014 by car, bike, or public transport. Filter to your max commute and the rest disappears, so you're only looking at areas that actually work.",
},
{
question: 'How is that better than checking Google Maps?',
answer:
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
},
],
},
{
question: 'How are current for-sale and for-rent listings found?',
answer:
'Properties currently on the market are sourced by periodically searching independent property portals (Rightmove, OnTheMarket, and Zoopla). These listings are fuzzy-matched by address to existing Land Registry records so that current asking prices appear alongside the historical sale price, EPC data, and all area-level statistics. You can filter by "Listing status" to show only properties currently for sale or for rent. When you click on a hexagon, you\'ll also see direct links to search Rightmove, OnTheMarket, and Zoopla for that area, pre-filled with your active price filters.',
title: 'Budget & Value',
items: [
{
question: 'How do I find areas where I get the most space for my money?',
answer:
"Filter by price per sqm \u2014 you'll instantly see which postcodes give you the most square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.",
},
{
question: "How do I make sure a cheap area isn't cheap for a reason?",
answer:
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable AND scores well on the stuff that matters, that's your hidden gem \u2014 not just a cheap postcode with a catch.",
},
],
},
{
question: 'What area does this cover?',
answer:
'England. The core datasets - Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax - all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
title: 'Safety & Neighbourhood',
items: [
{
question: 'How can I check if an area is safe before I move there?',
answer:
"We overlay real police-recorded crime data \u2014 broken down by type \u2014 onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers, so you're not relying on gut feeling.",
},
{
question: 'I keep finding flats that look great online, then the area turns out to be grim.',
answer:
"That's why we built this. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map \u2014 so you know what a neighbourhood is actually like before you waste a Saturday viewing.",
},
],
},
{
question: 'Why is data missing for my property?',
answer:
'There are a few common reasons. If a property has never been sold (or was last sold before Land Registry digital records began in 1995), there will be no price record. EPC data may be missing if the property has never had an energy assessment, or if the owner has opted out of public disclosure. Floor area, number of rooms, and energy ratings all come from EPC records, so a missing EPC means those fields will be blank. Finally, the fuzzy address matching between EPC and Land Registry records occasionally fails for unusual addresses.',
title: 'Families & Schools',
items: [
{
question: 'Can I find areas with good schools AND low crime in one search?',
answer:
'Yes \u2014 stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, then watch the map highlight only the areas that tick every box. No more cross-referencing five different websites with a spreadsheet.',
},
{
question: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
answer:
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
},
],
},
{
question: "How do I find areas that match what I'm looking for?",
answer:
'Use the Filters panel on the left. Add filters for the features you care about - for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source - this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
title: 'Environment & Quality of Life',
items: [
{
question: 'Can I find energy-efficient homes that aren\'t on a noisy road?',
answer:
'Filter by EPC rating (A\u2013C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
},
{
question: 'Does it show flood or subsidence risk?',
answer:
"We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before you fall in love with a property. Filter it out early and save yourself the surveyor's surprise.",
},
{
question: 'Can I find areas with fast broadband that are actually quiet?',
answer:
'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to spot the sweet spots instantly.',
},
],
},
{
question: 'How does the travel time feature work?',
answer:
'Click the travel time icon in the filters panel, search for a destination (any address or postcode in England), and choose a transport mode (car, bicycle, walking, or public transport). The map will colour hexagons by average journey time to that destination. You can add a time range filter to only show areas within, say, 30 minutes. Multiple destinations can be added simultaneously to find areas that are well-connected to several places.',
title: 'Why Perfect Postcode',
items: [
{
question: 'I already use Rightmove \u2014 what does this add?',
answer:
"Rightmove shows you houses. We show you areas. You'll see 56 layers of data \u2014 crime rates, school ratings, broadband speeds, noise levels, deprivation scores \u2014 all on one map, so you can judge a neighbourhood before you even look at listings.",
},
{
question: "Can't I just research all this myself for free?",
answer:
'You could spend weeks cross-referencing police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have all 56 datasets filterable and colour-coded on one map in seconds. Your time has a price too.',
},
{
question: 'Where does the data actually come from?',
answer:
"Every dataset comes from official UK government sources \u2014 Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up \u2014 you can verify any record against the original source.",
},
],
},
{
question: "Can I export the data I'm looking at?",
answer:
'Yes. Use the export button to download the currently filtered properties within your map view as an Excel spreadsheet. The export respects all your active filters, so you can narrow down to exactly the properties you want before downloading.',
title: 'Pricing & Access',
items: [
{
question: 'Is it really worth paying for a property search tool?',
answer:
"You're making a decision worth \u00a3200k\u2013\u00a3500k or more. Even spotting one red flag \u2014 a noisy road, poor broadband, rising crime \u2014 that changes your mind could save you years of regret. This costs less than a single viewing trip in petrol.",
},
{
question: "Is this another subscription that'll drain my account?",
answer:
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore all features within inner London (roughly zones 1\u20132). To access data for the rest of England, you need lifetime access.',
},
{
question: 'Can I get a refund?',
answer:
'Yes \u2014 we offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
},
],
},
{
question: 'What do the deprivation scores mean?',
answer:
'The English Indices of Deprivation 2025 rank every small area (LSOA, roughly 1,500 people) in England from most to least deprived. A rank of 1 means the most deprived area in the country. The scores cover seven domains: Income, Employment, Education, Health, Crime, Barriers to Housing & Services, and Living Environment. Each domain can be filtered independently. Lower rank numbers indicate higher deprivation.',
},
{
question: 'How reliable is the crime data at this scale?',
answer:
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA - an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
},
{
question: 'What does the school rating represent?',
answer:
'The school rating is the average Ofsted inspection outcome for state-funded schools near each postcode. Ofsted grades schools from 1 (Outstanding) to 4 (Inadequate). A value of 1.5 for a postcode means the nearby schools average between Outstanding and Good. This covers primary and secondary schools with inspection results as at April 2025.',
},
{
question: 'What happens when I zoom in very far?',
answer:
'At lower zoom levels, properties are grouped into hexagons that get smaller as you zoom in. When you zoom past level 15, the map switches from hexagons to individual postcode polygons, showing the actual postcode boundary shapes. Click any postcode polygon to see the properties within it.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL. Anyone who opens that link will see the same map position, zoom level, filters, pinned feature, and active POI categories.',
},
{
question: 'How can I remove my property from the map?',
answer:
'Property sale prices are public records from HM Land Registry and cannot be removed. EPC data (energy rating, floor area, number of rooms, etc.) can be removed by opting out of public disclosure through the government\u2019s official process at gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure. Once opted out, your EPC data will no longer appear in future data updates.',
},
{
question: 'How often is the data updated?',
answer:
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source. All updates are included with your access at no extra cost.',
},
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need lifetime access.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your access never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@perfect-postcode.co.uk within 30 days of purchase for a full refund.',
title: 'Tips & Tricks',
items: [
{
question: 'How do I use the AI filter instead of adding filters one by one?',
answer:
'Type what you want in plain English \u2014 something like "quiet area near good schools with fast broadband under \u00a3400k" \u2014 and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
},
{
question: 'Can I save a search and come back to it later?',
answer:
'Hit the save button and everything is captured \u2014 your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.',
},
{
question: "Can I export the data I'm looking at?",
answer:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
},
],
},
];
@ -409,12 +484,21 @@ export default function LearnPage() {
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Perfect Postcode works, where the data comes from, and how
to use the map.
Whether you&apos;re buying, renting, or just exploring &mdash; here&apos;s how Perfect
Postcode helps you find the right area.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
))}
</div>
</div>

View file

@ -61,6 +61,18 @@ export default memo(function AiFilterInput({
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const loadingMessage = useLoadingMessage(loading);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!expanded || loading) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [expanded, loading]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
@ -94,7 +106,7 @@ export default memo(function AiFilterInput({
if (!expanded) {
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
@ -110,7 +122,7 @@ export default memo(function AiFilterInput({
}
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>

View file

@ -43,7 +43,7 @@ export default function ExternalSearchLinks({
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{urls.rightmove ? (
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
@ -59,6 +59,11 @@ export default function ExternalSearchLinks({
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
{urls.openrent && (
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
OpenRent
</a>
)}
</div>
</div>
);

View file

@ -178,11 +178,19 @@ export default memo(function Filters({
[features, enabledFeatures]
);
const parkedFiltersRef = useRef<FeatureFilters>({});
const handleListingSelect = useCallback(
(type: ListingType) => {
// Track what will be active after swaps (to avoid conflicts with restoration)
const activeAfterSwaps = new Set<string>();
for (const name of Object.keys(filters)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
if (isAllowed(name, type)) {
activeAfterSwaps.add(name);
continue;
}
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
@ -191,15 +199,42 @@ export default memo(function Filters({
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
activeAfterSwaps.add(counterpart);
swapped = true;
break;
}
}
if (!swapped) {
parkedFiltersRef.current[name] = filters[name];
onRemoveFilter(name);
}
}
// Restore parked filters that are now allowed in the new mode
const restored: string[] = [];
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
onFilterChange(name, value);
activeAfterSwaps.add(name);
restored.push(name);
} else if (!isAllowed(name, type)) {
// Try restoring as linked counterpart
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
onFilterChange(counterpart, value);
activeAfterSwaps.add(counterpart);
restored.push(name);
break;
}
}
}
}
for (const name of restored) {
delete parkedFiltersRef.current[name];
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -232,7 +267,7 @@ export default memo(function Filters({
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
expandGroup('Travel Time');
expandGroup('Transport');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
@ -253,6 +288,16 @@ export default memo(function Filters({
[enabledFeatureList]
);
// Ensure "Transport" group exists in active filters when travel time entries are present
const mergedGroups = useMemo(() => {
if (travelTimeEntries.length === 0) return enabledGroups;
if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
const groups = [...enabledGroups];
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
return groups;
}, [enabledGroups, travelTimeEntries.length]);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
for (const f of features) {
@ -396,13 +441,13 @@ export default memo(function Filters({
<div className="flex items-center justify-between">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
@ -460,7 +505,6 @@ export default memo(function Filters({
<div className="flex items-center justify-between gap-1">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
className="min-w-0 shrink"
/>
@ -468,6 +512,7 @@ export default memo(function Filters({
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>

View file

@ -159,13 +159,6 @@ export default function Header({
>
Invite
</a>
<a
href={PAGE_PATHS.account}
className={tabClass('account')}
onClick={(e) => navLink('account', e)}
>
Account
</a>
</>
)}
<a
@ -245,7 +238,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
<UserMenu user={user} theme={theme} onToggleTheme={onToggleTheme} onLogout={onLogout} />
) : (
<>
<button
@ -275,8 +268,8 @@ export default function Header({
</button>
)}
{/* Theme toggle (desktop only) */}
{!isMobile && (
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
{!isMobile && !user && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"

View file

@ -1,7 +1,19 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
export default function UserMenu({
user,
theme,
onToggleTheme,
onLogout,
}: {
user: AuthUser;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onLogout: () => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -48,6 +60,17 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
</div>
</div>
<div className="p-1">
<button
onClick={onToggleTheme}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
{theme === 'light' ? (
<SunIcon className="w-4 h-4" />
) : (
<MoonIcon className="w-4 h-4" />
)}
Theme: {theme === 'light' ? 'Light' : 'Dark'}
</button>
<a
href="/account"
onClick={() => setOpen(false)}

View file

@ -24,6 +24,7 @@ export interface SavedProperty {
address: string;
postcode: string;
data: SavedPropertyData;
notes: string;
created: string;
}
@ -58,6 +59,7 @@ export function useSavedProperties(userId: string | null) {
address: raw.address as string,
postcode: raw.postcode as string,
data,
notes: (raw.notes as string) || '',
created: r.created,
};
})
@ -135,6 +137,15 @@ export function useSavedProperties(userId: string | null) {
[properties]
);
const updatePropertyNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_properties').update(id, { notes });
setProperties((prev) => prev.map((p) => (p.id === id ? { ...p, notes } : p)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update notes');
}
}, []);
return {
properties,
loading,
@ -144,5 +155,6 @@ export function useSavedProperties(userId: string | null) {
deleteProperty,
isPropertySaved,
getSavedPropertyId,
updatePropertyNotes,
};
}

View file

@ -8,6 +8,7 @@ export interface SavedSearch {
name: string;
params: string;
screenshotUrl: string;
notes: string;
created: string;
}
@ -34,6 +35,7 @@ export function useSavedSearches(userId: string | null) {
screenshotUrl: (r as Record<string, unknown>).screenshot
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
: '',
notes: ((r as Record<string, unknown>).notes as string) || '',
created: r.created,
}))
);
@ -72,9 +74,10 @@ export function useSavedSearches(userId: string | null) {
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.png');
patch.append('screenshot', blob, 'screenshot.jpg');
return pb.collection('saved_searches').update(record.id, patch);
})
.then(() => fetchSearches())
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
@ -100,5 +103,23 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_searches').update(id, { notes });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update notes');
}
}, []);
return {
searches,
loading,
saving,
error,
fetchSearches,
saveSearch,
deleteSearch,
updateSearchNotes,
};
}

View file

@ -79,7 +79,12 @@ export function buildPropertySearchUrls({
location,
filters,
rightmoveLocationId,
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
}: SearchUrlOptions): {
rightmove: string | null;
onthemarket: string;
zoopla: string;
openrent: string | null;
} | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
@ -192,5 +197,27 @@ export function buildPropertySearchUrls({
}
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
return { rightmove, onthemarket, zoopla };
// OpenRent — rent mode only
const listingStatus = filters['Listing status'];
const isRent =
Array.isArray(listingStatus) &&
typeof listingStatus[0] === 'string' &&
(listingStatus as string[]).includes('For rent');
let openrent: string | null = null;
if (isRent) {
const orParams = new URLSearchParams();
orParams.set('term', postcode);
const rentFilter = filters['Asking rent (monthly)'];
const minRent =
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
const maxRent =
Array.isArray(rentFilter) && typeof rentFilter[1] === 'number' ? rentFilter[1] : undefined;
if (minRent !== undefined) orParams.set('prices_min', String(Math.round(minRent)));
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
openrent = `https://www.openrent.com/properties-to-rent?${orParams.toString()}`;
}
return { rightmove, onthemarket, zoopla, openrent };
}