sus
This commit is contained in:
parent
920119ff48
commit
badab57438
3 changed files with 189 additions and 33 deletions
|
|
@ -6,7 +6,7 @@ import HexCanvas from './HexCanvas';
|
||||||
import HomeFinalCta from './HomeFinalCta';
|
import HomeFinalCta from './HomeFinalCta';
|
||||||
import BottomIllustration from './BottomIllustration';
|
import BottomIllustration from './BottomIllustration';
|
||||||
import { TickerValue } from '../ui/TickerValue';
|
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 { trackEvent } from '../../lib/analytics';
|
||||||
import { apiUrl } from '../../lib/api';
|
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';
|
'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_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
||||||
const HOME_PRIMARY_BUTTON_CLASS =
|
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<string, string> = {
|
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
|
||||||
en: 'recording',
|
en: 'recording',
|
||||||
de: 'recording-de',
|
de: 'recording-de',
|
||||||
|
|
@ -40,7 +40,7 @@ function getProductDemoSlug(language: string | undefined, isMobile: boolean): st
|
||||||
const code = language?.toLowerCase().split('-')[0] ?? 'en';
|
const code = language?.toLowerCase().split('-')[0] ?? 'en';
|
||||||
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
||||||
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
|
// Mobile cuts (9:16, 540x960) are published as `<base>-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.
|
// see video/src/storyboard.ts.
|
||||||
return isMobile ? `${base}-mobile` : base;
|
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 (
|
||||||
|
<div id={TWIN_SECTION_ID} className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-2`}>
|
||||||
|
<div className="mx-auto max-w-4xl rounded-2xl border border-warm-200 bg-white/90 p-6 shadow-sm dark:border-warm-700 dark:bg-warm-800/90 md:p-8">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<span className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-teal-700 dark:bg-teal-900/30 dark:text-teal-300">
|
||||||
|
{t('home.twinBadge')}
|
||||||
|
</span>
|
||||||
|
<h2 className={HOME_SECTION_HEADING_CLASS}>{t('home.twinTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className={`${HOME_BODY_CLASS} mb-6 max-w-2xl`}>{t('home.twinIntro')}</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-warm-200 bg-warm-50 p-5 dark:border-warm-700 dark:bg-navy-950/40">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||||
|
{t('home.twinPriceyLabel')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||||
|
{pricey.name}{' '}
|
||||||
|
<span className="text-base font-semibold text-warm-500 dark:text-warm-400">
|
||||||
|
{pricey.sector}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold tabular-nums text-warm-700 dark:text-warm-300">
|
||||||
|
{t('home.twinPerSqm', { value: fmt(pricey.perSqm) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border-2 border-teal-400 bg-teal-50/60 p-5 dark:border-teal-500 dark:bg-teal-900/20">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-teal-700 dark:text-teal-300">
|
||||||
|
{t('home.twinValueLabel')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||||
|
{value.name}{' '}
|
||||||
|
<span className="text-base font-semibold text-teal-700/70 dark:text-teal-300/70">
|
||||||
|
{value.sector}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold tabular-nums text-teal-700 dark:text-teal-300">
|
||||||
|
{t('home.twinPerSqm', { value: fmt(value.perSqm) })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-bold tabular-nums text-teal-700 dark:text-teal-300">
|
||||||
|
{t('home.twinDelta', { percent: `${deltaPct}%` })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col gap-2 text-sm font-medium text-warm-600 dark:text-warm-400 sm:flex-row sm:gap-6">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||||
|
{t('home.twinSameSchool')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||||
|
{t('home.twinSameCommute')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs italic text-warm-500 dark:text-warm-400">
|
||||||
|
{t('home.twinFootnote')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent('CTA Click', { location: 'twin', label: 'explore_map' });
|
||||||
|
onOpenDashboard();
|
||||||
|
}}
|
||||||
|
className={`mt-5 w-full sm:w-auto px-6 py-3 ${HOME_PRIMARY_BUTTON_CLASS}`}
|
||||||
|
>
|
||||||
|
{t('home.twinCta')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
onOpenPricing,
|
onOpenPricing,
|
||||||
|
|
@ -326,7 +413,7 @@ export default function HomePage({
|
||||||
// Scroll depth tracking
|
// Scroll depth tracking
|
||||||
const scrolledSections = useRef(new Set<string>());
|
const scrolledSections = useRef(new Set<string>());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = ['how-it-works'];
|
const ids = ['comparison'];
|
||||||
const observers: IntersectionObserver[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|
@ -352,8 +439,8 @@ export default function HomePage({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToProductDemoVideo = () => {
|
const scrollToSection = (sectionId: string) => {
|
||||||
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID);
|
const target = document.getElementById(sectionId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
|
|
@ -402,37 +489,38 @@ export default function HomePage({
|
||||||
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
|
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
|
||||||
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
|
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||||
onOpenDashboard();
|
onOpenDashboard();
|
||||||
}}
|
}}
|
||||||
className={`px-7 py-3.5 ${HOME_PRIMARY_BUTTON_CLASS}`}
|
className={`w-full sm:w-auto px-7 py-3.5 ${HOME_PRIMARY_BUTTON_CLASS}`}
|
||||||
>
|
>
|
||||||
{t('home.exploreTheMap')}
|
{t('home.exploreTheMap')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
||||||
scrollToProductDemoVideo();
|
scrollToSection(TWIN_SECTION_ID);
|
||||||
}}
|
}}
|
||||||
className="px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
|
className="w-full sm:w-auto px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/60"
|
||||||
>
|
>
|
||||||
{t('home.seeTheDifference')}
|
{t('home.seeTheDifference')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p>
|
||||||
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
|
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
|
||||||
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
|
<p className="text-sm text-warm-400 mb-6">{t('home.coverageNote')}</p>
|
||||||
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
||||||
<div className="home-hero-stat">
|
<div className="home-hero-stat">
|
||||||
<div className="home-hero-stat-value">
|
<div className="home-hero-stat-value tabular-nums">
|
||||||
<TickerValue text="13M" active={statsActive} />
|
<TickerValue text="13M" active={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero-stat-label">{t('home.statProperties')}</div>
|
<div className="home-hero-stat-label">{t('home.statProperties')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero-stat">
|
<div className="home-hero-stat">
|
||||||
<div className="home-hero-stat-value">
|
<div className="home-hero-stat-value tabular-nums">
|
||||||
<TickerValue text="40+" active={statsActive} />
|
<TickerValue text="40+" active={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
||||||
|
|
@ -442,6 +530,10 @@ export default function HomePage({
|
||||||
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
|
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-5 text-xs font-medium uppercase tracking-wide text-warm-400">
|
||||||
|
<span className="text-warm-300">{t('home.dataSourcesLabel')}:</span>{' '}
|
||||||
|
{t('home.dataSources')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={<ProductShowcaseFallback />}>
|
<Suspense fallback={<ProductShowcaseFallback />}>
|
||||||
<ProductShowcase />
|
<ProductShowcase />
|
||||||
|
|
@ -454,7 +546,7 @@ export default function HomePage({
|
||||||
aria-label={t('home.scrollToProductDemo')}
|
aria-label={t('home.scrollToProductDemo')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
|
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
|
||||||
scrollToProductDemoVideo();
|
scrollToSection(TWIN_SECTION_ID);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronIcon direction="down" className="h-14 w-14" />
|
<ChevronIcon direction="down" className="h-14 w-14" />
|
||||||
|
|
@ -463,7 +555,8 @@ export default function HomePage({
|
||||||
|
|
||||||
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
|
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<ProductDemoVideo />
|
{/* Concrete proof of the "cheaper twin" claim, high on the page */}
|
||||||
|
<TwinProof onOpenDashboard={onOpenDashboard} />
|
||||||
|
|
||||||
{/* Our philosophy */}
|
{/* Our philosophy */}
|
||||||
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}>
|
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}>
|
||||||
|
|
@ -509,12 +602,9 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comparison table */}
|
{/* Comparison table */}
|
||||||
<div
|
<div id="comparison" className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
|
||||||
id="how-it-works"
|
|
||||||
className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}
|
|
||||||
>
|
|
||||||
<div ref={whyRef} className="fade-in-section">
|
<div ref={whyRef} className="fade-in-section">
|
||||||
<div id="comparison">
|
<div>
|
||||||
<h2
|
<h2
|
||||||
className={`${HOME_SECTION_HEADING_CLASS} mb-6 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center md:mb-10 md:gap-x-3`}
|
className={`${HOME_SECTION_HEADING_CLASS} mb-6 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center md:mb-10 md:gap-x-3`}
|
||||||
>
|
>
|
||||||
|
|
@ -547,24 +637,35 @@ export default function HomePage({
|
||||||
subtitle: t('home.compSearchWithoutSub'),
|
subtitle: t('home.compSearchWithoutSub'),
|
||||||
postcode: false,
|
postcode: false,
|
||||||
guides: false,
|
guides: false,
|
||||||
|
perfect: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature: t('home.compAreaData'),
|
feature: t('home.compAreaData'),
|
||||||
subtitle: t('home.compAreaDataSub'),
|
subtitle: t('home.compAreaDataSub'),
|
||||||
postcode: true,
|
postcode: true,
|
||||||
guides: true,
|
guides: true,
|
||||||
|
perfect: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature: t('home.compPropertyData'),
|
feature: t('home.compPropertyData'),
|
||||||
subtitle: t('home.compPropertyDataSub'),
|
subtitle: t('home.compPropertyDataSub'),
|
||||||
postcode: false,
|
postcode: false,
|
||||||
guides: true,
|
guides: true,
|
||||||
|
perfect: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature: t('home.compFilters'),
|
feature: t('home.compFilters'),
|
||||||
subtitle: t('home.compFiltersSub'),
|
subtitle: t('home.compFiltersSub'),
|
||||||
postcode: false,
|
postcode: false,
|
||||||
guides: false,
|
guides: false,
|
||||||
|
perfect: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feature: t('home.compListings'),
|
||||||
|
subtitle: t('home.compListingsSub'),
|
||||||
|
postcode: true,
|
||||||
|
guides: false,
|
||||||
|
perfect: false,
|
||||||
},
|
},
|
||||||
].map((row, i, arr) => (
|
].map((row, i, arr) => (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -597,11 +698,15 @@ export default function HomePage({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td
|
<td
|
||||||
aria-label={t('common.yes')}
|
aria-label={row.perfect ? t('common.yes') : t('common.no')}
|
||||||
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
|
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base bg-teal-50 dark:bg-teal-900/30 ${
|
||||||
|
row.perfect ? 'text-green-500' : 'text-red-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">✓</span>
|
<span aria-hidden="true">{row.perfect ? '✓' : '✗'}</span>
|
||||||
<span className="sr-only">{t('common.yes')}</span>
|
<span className="sr-only">
|
||||||
|
{row.perfect ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -612,6 +717,10 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Full product walkthrough, kept as a later proof beat, after the
|
||||||
|
differentiation, instead of competing with the showcase in the hero */}
|
||||||
|
<ProductDemoVideo />
|
||||||
|
|
||||||
{/* The real cost CTA */}
|
{/* The real cost CTA */}
|
||||||
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-14 pb-12`}>
|
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-14 pb-12`}>
|
||||||
<div ref={ctaRef} className="fade-in-section">
|
<div ref={ctaRef} className="fade-in-section">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export const BUFFER_MULTIPLIER = 1;
|
||||||
/** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */
|
/** 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 };
|
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). */
|
* stays testable without a real fix (e.g. over http, where browsers deny geolocation). */
|
||||||
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
|
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
|
/** 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`. */
|
* Must match the server-side `DEMO_MAX_FILTERS`. */
|
||||||
export const DEMO_MAX_FILTERS = 3;
|
export const DEMO_MAX_FILTERS = 3;
|
||||||
|
|
||||||
/** Registered but non-paying users get a higher filter allowance than anonymous
|
/** 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`. */
|
* unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */
|
||||||
export const REGISTERED_MAX_FILTERS = 5;
|
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;
|
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<string, [number, number]> = {
|
||||||
|
// 'Est. price per sqm': [0, 7000],
|
||||||
|
// 'Good+ secondary school catchments': [1, 11],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zoom to H3 resolution mapping thresholds.
|
* Zoom to H3 resolution mapping thresholds.
|
||||||
* Returns the H3 resolution to use for a given zoom level.
|
* 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)
|
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shared limit at which the zoomed-in detail layers — postcodes AND the map
|
// Shared limit at which the zoomed-in detail layers, postcodes AND the map
|
||||||
// overlays (noise, crime, trees, property borders) — start showing. Set just
|
// overlays (noise, crime, trees, property borders), start showing. Set just
|
||||||
// past the finest hexagon level so detail appears while still relatively
|
// past the finest hexagon level so detail appears while still relatively
|
||||||
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
||||||
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
|
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
|
||||||
|
|
@ -115,7 +126,7 @@ export function getFeatureGradient(featureName: string | null | undefined): Grad
|
||||||
: FEATURE_GRADIENT;
|
: 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] }[] = [
|
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [255, 255, 255] },
|
{ t: 0, color: [255, 255, 255] },
|
||||||
{ t: 0.1, color: [248, 233, 211] },
|
{ 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] },
|
{ 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] }[] = [
|
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [55, 45, 35] },
|
{ t: 0, color: [55, 45, 35] },
|
||||||
{ t: 0.1, color: [85, 65, 40] },
|
{ 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],
|
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. */
|
* polygon outline stays legible. */
|
||||||
export const FILTERED_OUT_LINE: Record<'light' | 'dark', [number, number, number, number]> = {
|
export const FILTERED_OUT_LINE: Record<'light' | 'dark', [number, number, number, number]> = {
|
||||||
light: [110, 110, 110, 80],
|
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;
|
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
|
/** 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. */
|
* show a spaced subset and rely on the map markers (+ hover) for the rest. */
|
||||||
export const MAX_AUTO_POI_CARDS = 40;
|
export const MAX_AUTO_POI_CARDS = 40;
|
||||||
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
|
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
|
||||||
|
|
|
||||||
36
server-rs/src/generated_data_pages.rs
Normal file
36
server-rs/src/generated_data_pages.rs
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue