From badab57438470e37eb13ddba5dc39017fe34b057 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 12 Jul 2026 15:11:21 +0100 Subject: [PATCH] sus --- frontend/src/components/home/HomePage.tsx | 157 ++++++++++++++++++---- frontend/src/lib/consts.ts | 29 ++-- server-rs/src/generated_data_pages.rs | 36 +++++ 3 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 server-rs/src/generated_data_pages.rs diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index d43ba0b..1d9e52e 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -6,7 +6,7 @@ import HexCanvas from './HexCanvas'; import HomeFinalCta from './HomeFinalCta'; import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; -import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; +import { CheckIcon, ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; import { trackEvent } from '../../lib/analytics'; import { apiUrl } from '../../lib/api'; @@ -17,7 +17,7 @@ const HOME_SECTION_HEADING_CLASS = 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_PRIMARY_BUTTON_CLASS = - 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/80'; const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record = { en: 'recording', de: 'recording-de', @@ -40,7 +40,7 @@ function getProductDemoSlug(language: string | undefined, isMobile: boolean): st const code = language?.toLowerCase().split('-')[0] ?? 'en'; const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en; // Mobile cuts (9:16, 540x960) are published as `-mobile` alongside - // the 16:9 desktop cuts. The recorder pipeline writes both every render — + // the 16:9 desktop cuts. The recorder pipeline writes both every render: // see video/src/storyboard.ts. return isMobile ? `${base}-mobile` : base; } @@ -238,6 +238,93 @@ function PriceStrip({ ); } +const TWIN_SECTION_ID = 'twin-proof'; + +/** + * "Cheaper twin" proof block: real, verified figures (not illustrative). + * Median £/sqm for Land Registry "Flats/Maisonettes" sold since 2021, derived from + * epc_pp.parquet; school/commute parity checked against postcode.parquet + modelled + * transit times to central London. Sector grain (N1 1 / N7 9) is LOAD-BEARING. Do + * not relabel as bare N1/N7: the gap collapses to ~14% at outward-district level. + * Re-run the query periodically as new sales land; figures drift slightly over time. + */ +function TwinProof({ onOpenDashboard }: { onOpenDashboard: () => void }) { + const { t } = useTranslation(); + const pricey = { name: 'Angel', sector: 'N1 1', perSqm: 11356 }; + const value = { name: 'Holloway', sector: 'N7 9', perSqm: 7943 }; + const deltaPct = Math.round((1 - value.perSqm / pricey.perSqm) * 100); + const fmt = (n: number) => `£${n.toLocaleString('en-GB')}`; + + return ( +
+
+
+ + {t('home.twinBadge')} + +

{t('home.twinTitle')}

+
+

{t('home.twinIntro')}

+
+
+
+ {t('home.twinPriceyLabel')} +
+
+ {pricey.name}{' '} + + {pricey.sector} + +
+
+ {t('home.twinPerSqm', { value: fmt(pricey.perSqm) })} +
+
+
+
+ {t('home.twinValueLabel')} +
+
+ {value.name}{' '} + + {value.sector} + +
+
+ {t('home.twinPerSqm', { value: fmt(value.perSqm) })} +
+
+ {t('home.twinDelta', { percent: `${deltaPct}%` })} +
+
+
+
+ + + {t('home.twinSameSchool')} + + + + {t('home.twinSameCommute')} + +
+

+ {t('home.twinFootnote')} +

+ +
+
+ ); +} + export default function HomePage({ onOpenDashboard, onOpenPricing, @@ -326,7 +413,7 @@ export default function HomePage({ // Scroll depth tracking const scrolledSections = useRef(new Set()); useEffect(() => { - const ids = ['how-it-works']; + const ids = ['comparison']; const observers: IntersectionObserver[] = []; ids.forEach((id) => { const el = document.getElementById(id); @@ -352,8 +439,8 @@ export default function HomePage({ return () => clearTimeout(timer); }, []); - const scrollToProductDemoVideo = () => { - const target = document.getElementById(PRODUCT_DEMO_SECTION_ID); + const scrollToSection = (sectionId: string) => { + const target = document.getElementById(sectionId); if (!target) return; const scroller = target.closest('.overflow-y-auto') as HTMLElement | null; if (!scroller) return; @@ -402,37 +489,38 @@ export default function HomePage({

{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}

-
+
+

{t('home.freeToExplore')}

-

{t('home.coverageNote')}

+

{t('home.coverageNote')}

-
+
{t('home.statProperties')}
-
+
{t('home.statFilters')}
@@ -442,6 +530,10 @@ export default function HomePage({
{t('home.statPostcodeInEngland')}
+

+ {t('home.dataSourcesLabel')}:{' '} + {t('home.dataSources')} +

}> @@ -454,7 +546,7 @@ export default function HomePage({ aria-label={t('home.scrollToProductDemo')} onClick={() => { trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' }); - scrollToProductDemoVideo(); + scrollToSection(TWIN_SECTION_ID); }} > @@ -463,7 +555,8 @@ export default function HomePage({
- + {/* Concrete proof of the "cheaper twin" claim, high on the page */} + {/* Our philosophy */}
@@ -509,12 +602,9 @@ export default function HomePage({
{/* Comparison table */} -
+
-
+

@@ -547,24 +637,35 @@ export default function HomePage({ subtitle: t('home.compSearchWithoutSub'), postcode: false, guides: false, + perfect: true, }, { feature: t('home.compAreaData'), subtitle: t('home.compAreaDataSub'), postcode: true, guides: true, + perfect: true, }, { feature: t('home.compPropertyData'), subtitle: t('home.compPropertyDataSub'), postcode: false, guides: true, + perfect: true, }, { feature: t('home.compFilters'), subtitle: t('home.compFiltersSub'), postcode: false, guides: false, + perfect: true, + }, + { + feature: t('home.compListings'), + subtitle: t('home.compListingsSub'), + postcode: true, + guides: false, + perfect: false, }, ].map((row, i, arr) => ( - - {t('common.yes')} + + + {row.perfect ? t('common.yes') : t('common.no')} + ))} @@ -612,6 +717,10 @@ export default function HomePage({

+ {/* Full product walkthrough, kept as a later proof beat, after the + differentiation, instead of competing with the showcase in the hero */} + + {/* The real cost CTA */}
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 8fe6438..150ca41 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -16,7 +16,7 @@ export const BUFFER_MULTIPLIER = 1; /** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */ export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 }; -/** 10 Downing Street — geolocation fallback in dev so the "use my location" flow +/** 10 Downing Street: geolocation fallback in dev so the "use my location" flow * stays testable without a real fix (e.g. over http, where browsers deny geolocation). */ export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 }; @@ -28,12 +28,12 @@ export const INITIAL_VIEW_STATE: ViewState = { }; /** Anonymous (logged-out) users can apply at most this many filters at a time. The - * full dashboard is otherwise available everywhere — there is no region lock. + * full dashboard is otherwise available everywhere. There is no region lock. * Must match the server-side `DEMO_MAX_FILTERS`. */ export const DEMO_MAX_FILTERS = 3; /** Registered but non-paying users get a higher filter allowance than anonymous - * visitors — the carrot for creating a free account. Paying/licensed users are + * visitors: the carrot for creating a free account. Paying/licensed users are * unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */ export const REGISTERED_MAX_FILTERS = 5; @@ -44,6 +44,17 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu return isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS; } +/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value + * or the 3-filter cap. These two high-intent filters (value for money + good secondary schools) + * are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful + * and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry + * their own filters and are left untouched. Unknown feature names are dropped safely by useFilters. + * Tune or empty this object to change/disable the behaviour. */ +export const DEFAULT_DEMO_FILTERS: Record = { + // 'Est. price per sqm': [0, 7000], + // 'Good+ secondary school catchments': [1, 11], +}; + /** * Zoom to H3 resolution mapping thresholds. * Returns the H3 resolution to use for a given zoom level. @@ -60,8 +71,8 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max( ...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution) ); -// Shared limit at which the zoomed-in detail layers — postcodes AND the map -// overlays (noise, crime, trees, property borders) — start showing. Set just +// Shared limit at which the zoomed-in detail layers, postcodes AND the map +// overlays (noise, crime, trees, property borders), start showing. Set just // past the finest hexagon level so detail appears while still relatively // zoomed out. (Each overlay additionally can't render below its own tile-data // floor, OVERLAY_MIN_ZOOM, regardless of this limit.) @@ -115,7 +126,7 @@ export function getFeatureGradient(featureName: string | null | undefined): Grad : FEATURE_GRADIENT; } -/** Number of properties gradient — light mode (cream → orange) */ +/** Number of properties gradient: light mode (cream → orange) */ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [ { t: 0, color: [255, 255, 255] }, { t: 0.1, color: [248, 233, 211] }, @@ -124,7 +135,7 @@ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] { t: 1, color: [255, 162, 31] }, ]; -/** Number of properties gradient — dark mode (dark warm → bright amber) */ +/** Number of properties gradient: dark mode (dark warm → bright amber) */ export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [ { t: 0, color: [55, 45, 35] }, { t: 0.1, color: [85, 65, 40] }, @@ -144,7 +155,7 @@ export const FILTERED_OUT_FILL: Record<'light' | 'dark', [number, number, number dark: [120, 114, 108, 50], }; -/** Border for a filtered-out postcode — a touch stronger than its fill so the +/** Border for a filtered-out postcode: a touch stronger than its fill so the * polygon outline stays legible. */ export const FILTERED_OUT_LINE: Record<'light' | 'dark', [number, number, number, number]> = { light: [110, 110, 110, 80], @@ -256,7 +267,7 @@ export const POI_CLUSTER_MAX_ZOOM = 15; export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1; /** Hard cap on auto POI cards rendered at once. With every category enabled a - * dense area can yield hundreds of overlapping cards — an unreadable wall — so we + * dense area can yield hundreds of overlapping cards (an unreadable wall), so we * show a spaced subset and rely on the map markers (+ hover) for the rest. */ export const MAX_AUTO_POI_CARDS = 40; /** Minimum screen-space gap (px) between two auto POI cards before one is culled. */ diff --git a/server-rs/src/generated_data_pages.rs b/server-rs/src/generated_data_pages.rs new file mode 100644 index 0000000..fd00264 --- /dev/null +++ b/server-rs/src/generated_data_pages.rs @@ -0,0 +1,36 @@ +// @generated by analysis/build_pages.py. Do not edit by hand. +// Registers the data-driven growth pages so og_middleware serves them (not 404) with the +// right title/description and an OG card pointed at the finding's map view. + +pub struct DataPage { + pub path: &'static str, + pub title: &'static str, + pub description: &'static str, + /// Map query string the OG screenshot should frame (empty = default map). + pub screenshot_query: &'static str, +} + +pub static DATA_PAGES: &[DataPage] = &[ + DataPage { path: "/cheaper-twin/br3-3-vs-cr0-7", title: "Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²", description: "£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart", screenshot_query: "lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/ha7-2-vs-ha3-0", title: "Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²", description: "£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart", screenshot_query: "lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/ig8-7-vs-ig6-2", title: "Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²", description: "£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart", screenshot_query: "lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/l16-7-vs-l14-6", title: "Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²", description: "£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart", screenshot_query: "lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/m40-5-vs-m9-4", title: "Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²", description: "£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart", screenshot_query: "lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/rm14-2-vs-rm12-5", title: "Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²", description: "£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart", screenshot_query: "lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/se28-8-vs-da18-4", title: "SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²", description: "£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart", screenshot_query: "lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/sw1x-8-vs-sw7-2", title: "SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²", description: "£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart", screenshot_query: "lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/tw12-3-vs-kt8-1", title: "Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²", description: "£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart", screenshot_query: "lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/tw2-7-vs-tw3-2", title: "Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²", description: "£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart", screenshot_query: "lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/w1j-7-vs-sw7-3", title: "W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²", description: "£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart", screenshot_query: "lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/w1j-8-vs-sw1a-2", title: "W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²", description: "£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart", screenshot_query: "lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/w1k-2-vs-sw1x-0", title: "W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²", description: "£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart", screenshot_query: "lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/w1u-4-vs-nw1-4", title: "Marylebone vs Camden: the same flat, about 43% cheaper per m²", description: "£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart", screenshot_query: "lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/cheaper-twin/wc2a-2-vs-ec2a-2", title: "WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²", description: "£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart", screenshot_query: "lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11" }, + DataPage { path: "/square-metres-per-100k", title: "How many square metres £100,000 buys across England", description: "£100k buys ~152 m² of floor space in BD21 3 but only ~3 m² in Mayfair (W1K 2)", screenshot_query: "zoom=6&filter=Est.%20price%20per%20sqm:0:4000" }, + DataPage { path: "/cheaper-twins", title: "Cheaper twin postcodes in England", description: "Neighbouring England postcodes priced apart for the name, not the home.", screenshot_query: "" }, +]; + +/// Look up a generated data page by request path (already trailing-slash-trimmed). +pub fn data_page(path: &str) -> Option<&'static DataPage> { + DATA_PAGES.iter().find(|p| p.path == path) +}