Fix data pipelines once and for all

This commit is contained in:
Andras Schmelczer 2026-06-10 21:27:32 +01:00
parent 08560476c5
commit 4012e4e047
46 changed files with 4508 additions and 855 deletions

View file

@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next';
import { usePageMeta } from '../../hooks/usePageMeta';
import Footer from '../ui/Footer';
import { PRIVACY, TERMS, type LegalDoc } from './legal-content';
export type LegalKind = 'terms' | 'privacy';
const DOCS: Record<LegalKind, LegalDoc> = { terms: TERMS, privacy: PRIVACY };
export default function LegalPage({ kind }: { kind: LegalKind }) {
const { t, i18n } = useTranslation();
const doc = DOCS[kind];
usePageMeta(`${doc.title} | Perfect Postcode`, doc.metaDescription);
const showEnglishNotice = !i18n.language?.toLowerCase().startsWith('en');
return (
<main className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="mx-auto max-w-3xl px-4 py-10 sm:py-14">
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100">{doc.title}</h1>
<p className="mt-2 text-sm text-warm-500 dark:text-warm-400">
{t('legal.lastUpdated', { date: doc.lastUpdated })}
</p>
{showEnglishNotice && (
<p className="mt-2 text-sm italic text-warm-500 dark:text-warm-400">
{t('legal.englishOnly')}
</p>
)}
<div className="mt-6 space-y-4">
{doc.intro.map((paragraph) => (
<p key={paragraph} className="leading-relaxed text-warm-700 dark:text-warm-300">
{paragraph}
</p>
))}
</div>
<div className="mt-8 space-y-8">
{doc.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p
key={paragraph}
className="mt-2 leading-relaxed text-warm-700 dark:text-warm-300"
>
{paragraph}
</p>
))}
{section.bullets && (
<ul className="mt-2 list-disc space-y-1.5 pl-5 text-warm-700 dark:text-warm-300">
{section.bullets.map((bullet) => (
<li key={bullet} className="leading-relaxed">
{bullet}
</li>
))}
</ul>
)}
</section>
))}
</div>
</div>
<Footer />
</main>
);
}

View file

@ -0,0 +1,183 @@
/**
* Legal documents are maintained in English only; the English text is the
* authoritative version (a localized notice says so on the page). Keeping
* legal copy out of the i18n catalogues avoids meaning drift in translation.
*
* TODO before launch: confirm the operator/legal-entity details below.
*/
export interface LegalSection {
heading: string;
paragraphs: string[];
bullets?: string[];
}
export interface LegalDoc {
title: string;
metaDescription: string;
lastUpdated: string;
intro: string[];
sections: LegalSection[];
}
export const SUPPORT_EMAIL = 'support@perfect-postcode.co.uk';
export const TERMS: LegalDoc = {
title: 'Terms of Service',
metaDescription:
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
lastUpdated: '10 June 2026',
intro: [
`These terms govern your use of perfect-postcode.co.uk ("Perfect Postcode", "the service", "we", "us"). By creating an account or purchasing access you agree to them. If you have any questions, contact ${SUPPORT_EMAIL}.`,
],
sections: [
{
heading: '1. The service',
paragraphs: [
'Perfect Postcode is a research tool that combines public datasets about England — property transactions, energy certificates, schools, crime, noise, broadband, transport and more — on an interactive map, so you can shortlist areas that fit your needs before booking viewings.',
'We are not an estate agent, mortgage broker, surveyor or financial adviser, and the service does not provide financial, legal or investment advice.',
],
},
{
heading: '2. Accounts',
paragraphs: [
'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
],
},
{
heading: '3. Free demo and lifetime access',
paragraphs: [
'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
],
},
{
heading: '4. Acceptable use',
paragraphs: ['You agree not to:'],
bullets: [
'scrape, crawl or bulk-download data outside the export tools we provide;',
'resell, republish or redistribute the data or substantial extracts of it;',
'probe, disrupt or place unreasonable load on the service;',
'use the AI search or other features to process content you have no right to submit.',
],
},
{
heading: '5. Data accuracy',
paragraphs: [
'The maps and figures are built from public datasets (HM Land Registry, EPC register, ONS, Ofsted, DfT, police.uk and others) combined with modelling and estimation. Sources can be incomplete, out of date or wrong at the level of an individual property, and our estimates — including estimated current prices — are statistical indications, not valuations.',
'Always verify anything that matters in person and through professional advice (surveys, solicitors, mortgage advisers) before making offers or financial decisions. We provide the service "as is" and do not warrant that any figure is accurate, complete or current.',
],
},
{
heading: '6. Payments and refunds',
paragraphs: [
'Payments are processed by Stripe; we never see or store your card details. Prices are shown in pounds sterling at checkout. Early-access pricing tiers can change as tiers fill; the price shown at the moment you pay is the price you get.',
`If Perfect Postcode is not for you, email ${SUPPORT_EMAIL} within 14 days of purchase and we will refund you in full.`,
],
},
{
heading: '7. Third-party content',
paragraphs: [
'Street View imagery, listing-portal links and similar embedded content are provided by third parties and governed by their own terms. We are not responsible for their availability or accuracy.',
],
},
{
heading: '8. Liability',
paragraphs: [
'To the extent permitted by law, we are not liable for decisions made in reliance on the data, for indirect or consequential losses, or for interruptions to the service; our total liability to you is limited to the amount you paid us. Nothing in these terms excludes liability that cannot legally be excluded, and nothing affects your statutory rights as a consumer.',
],
},
{
heading: '9. Changes to the service or these terms',
paragraphs: [
'We are a small product that improves continuously; features and data sources may change. We may update these terms, and will note the date of the latest revision above. If a change is material we will flag it on the site or by email. Continued use after a change means you accept the updated terms.',
],
},
{
heading: '10. Governing law and contact',
paragraphs: [
`These terms are governed by the law of England and Wales, and disputes are subject to the jurisdiction of the courts of England and Wales (consumers keep any mandatory protections of their country of residence). Questions and complaints: ${SUPPORT_EMAIL} — we typically respond within 24 hours.`,
],
},
],
};
export const PRIVACY: LegalDoc = {
title: 'Privacy Policy',
metaDescription:
'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.',
lastUpdated: '10 June 2026',
intro: [
`This policy explains what personal data Perfect Postcode ("we", "us") collects, why, and your rights over it. We handle personal data under UK data-protection law (UK GDPR and the Data Protection Act 2018). Contact: ${SUPPORT_EMAIL}.`,
],
sections: [
{
heading: '1. What we collect',
paragraphs: [],
bullets: [
'Account data: your email address, a hashed password (or your Google account identifier if you sign in with Google), newsletter preference and access status.',
'Purchase records: what you bought and when. Payments are processed by Stripe; we never receive your card details.',
'Things you create: saved searches, shared links and their settings.',
'AI search queries: the text you type into the AI search is processed to generate filters and logged with your account so we can debug and improve the feature.',
'Usage data: which pages and features are used, collected as events for product analytics, and standard server logs (IP address, user agent) kept for security.',
],
},
{
heading: '2. How we use it',
paragraphs: [],
bullets: [
'To provide and secure the service, including signing you in and remembering your saved work (performance of contract).',
'To process payments and keep the records tax law requires (legal obligation).',
'To answer support requests (performance of contract).',
'To send the newsletter, only if you opted in — every email includes an unsubscribe link (consent).',
'To understand how features are used and improve them, using aggregated analytics and logged AI queries (legitimate interests).',
],
},
{
heading: '3. Who we share it with',
paragraphs: [
'We do not sell personal data. We use a small number of processors to run the service:',
],
bullets: [
'Stripe — payment processing.',
'Google — sign-in (if you choose Google OAuth), embedded Maps/Street View imagery, and the Gemini API which processes the text of AI searches.',
'Hosting and infrastructure providers that run our servers and store backups.',
],
},
{
heading: '4. International transfers',
paragraphs: [
'Some processors (such as Stripe and Google) process data outside the UK. Where that happens, transfers rely on UK adequacy decisions or standard contractual clauses.',
],
},
{
heading: '5. Cookies and local storage',
paragraphs: [
'We do not use advertising cookies or third-party trackers. Your browsers local storage holds your sign-in token and preferences (theme, language, tutorial progress, last map view). Embedded Google content (Street View, sign-in) may set its own cookies under Googles policies.',
],
},
{
heading: '6. Retention',
paragraphs: [
'Account data is kept while your account exists and deleted when you ask us to close it. Server logs are kept for a short period for security. Purchase records are kept for as long as tax law requires (typically six years).',
],
},
{
heading: '7. Your rights',
paragraphs: [
`You can ask for a copy of your data, have it corrected or deleted, restrict or object to processing, and receive your data in a portable format. Email ${SUPPORT_EMAIL} and we will respond promptly. If you are unhappy with how we handle your data you can complain to the Information Commissioners Office (ico.org.uk).`,
],
},
{
heading: '8. Children',
paragraphs: ['The service is aimed at home buyers and renters and is not directed at children under 16.'],
},
{
heading: '9. Changes to this policy',
paragraphs: [
'We will post any changes here and update the date at the top. Material changes will be flagged on the site or by email.',
],
},
],
};

View file

@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import { LogoIcon } from './icons/LogoIcon';
const SUPPORT_EMAIL = 'support@perfect-postcode.co.uk';
function FooterLink({ href, label }: { href: string; label: string }) {
return (
<li>
<a
href={href}
className="text-sm text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400 transition-colors"
>
{label}
</a>
</li>
);
}
export default function Footer() {
const { t } = useTranslation();
const year = new Date().getFullYear();
return (
<footer className="border-t border-warm-200 bg-warm-50 dark:border-warm-800 dark:bg-navy-950">
<div className="mx-auto max-w-6xl px-4 py-10">
<div className="grid gap-8 sm:grid-cols-2 md:grid-cols-4">
<div>
<a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<LogoIcon className="h-5 w-5 shrink-0 text-teal-500" />
<span className="text-base font-semibold text-navy-950 dark:text-teal-300">
{t('header.appName')}
</span>
</a>
<p className="mt-3 text-sm leading-relaxed text-warm-500 dark:text-warm-400">
{t('footer.tagline')}
</p>
</div>
<nav aria-label={t('footer.product')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.product')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/dashboard" label={t('header.dashboard')} />
<FooterLink href="/pricing" label={t('header.pricing')} />
<FooterLink href="/learn" label={t('header.learn')} />
</ul>
</nav>
<nav aria-label={t('footer.resources')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.resources')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/data-sources" label={t('footer.dataSources')} />
<FooterLink href="/methodology" label={t('footer.methodology')} />
<FooterLink href={`mailto:${SUPPORT_EMAIL}`} label={t('footer.contact')} />
</ul>
</nav>
<nav aria-label={t('footer.legal')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.legal')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/terms" label={t('footer.terms')} />
<FooterLink href="/privacy" label={t('footer.privacy')} />
</ul>
</nav>
</div>
<div className="mt-10 flex flex-col gap-2 border-t border-warm-200 pt-6 text-xs text-warm-400 dark:border-warm-800 dark:text-warm-500 sm:flex-row sm:items-center sm:justify-between">
<p>{t('footer.copyright', { year })}</p>
<p>{t('footer.coverage')}</p>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
/**
* Tracks whether dark mode is active by observing the html.dark class.
* Useful in components that don't receive the theme as a prop (showcase,
* pricing backdrop) but must keep canvas/map content in sync with it.
*/
export function useIsDarkTheme(): boolean {
const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark'));
useEffect(() => {
const observer = new MutationObserver(() =>
setIsDark(document.documentElement.classList.contains('dark'))
);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { MAP_MIN_ZOOM } from './consts';
import { boundsToCenterZoom } from './fit-bounds';
describe('boundsToCenterZoom', () => {
it('centers on the middle of the box', () => {
const target = boundsToCenterZoom({ south: 51.4, north: 51.6, west: -0.3, east: 0.1 });
expect(target.lat).toBeCloseTo(51.5, 5);
expect(target.lng).toBeCloseTo(-0.1, 5);
});
it('zooms close for a small box and far out for a country-sized box', () => {
const street = boundsToCenterZoom({ south: 51.5, north: 51.51, west: -0.11, east: -0.1 });
const england = boundsToCenterZoom({ south: 50.0, north: 55.5, west: -5.7, east: 1.8 });
expect(street.zoom).toBeGreaterThan(england.zoom);
expect(england.zoom).toBeGreaterThanOrEqual(MAP_MIN_ZOOM);
// Greater London-ish box should land in a sensible city-scale zoom range
const london = boundsToCenterZoom({ south: 51.44, north: 51.59, west: -0.31, east: 0.05 });
expect(london.zoom).toBeGreaterThan(8);
expect(london.zoom).toBeLessThan(12);
});
it('caps zoom-in for degenerate (single point) boxes', () => {
const point = boundsToCenterZoom({ south: 51.5, north: 51.5, west: -0.1, east: -0.1 });
expect(point.zoom).toBeLessThanOrEqual(13);
});
it('tolerates swapped corners', () => {
const target = boundsToCenterZoom({ south: 51.6, north: 51.4, west: 0.1, east: -0.3 });
expect(target.lat).toBeCloseTo(51.5, 5);
expect(target.lng).toBeCloseTo(-0.1, 5);
expect(Number.isFinite(target.zoom)).toBe(true);
});
});

View file

@ -0,0 +1,45 @@
import { MAP_MIN_ZOOM } from './consts';
export interface GeoBounds {
south: number;
west: number;
north: number;
east: number;
}
/**
* Nominal viewport used to derive a zoom from a bounding box. The map only
* exposes flyTo(lat, lng, zoom), so we approximate fitBounds; being half a
* zoom level off for unusual window sizes is fine for "show me the matches".
*/
const NOMINAL_VIEWPORT = { width: 1000, height: 700 };
const TILE_SIZE = 512;
/** Keep matches comfortably inside the viewport edges. */
const ZOOM_PADDING = 0.4;
const MAX_FIT_ZOOM = 13;
function mercatorY(lat: number): number {
const rad = (lat * Math.PI) / 180;
return Math.log(Math.tan(Math.PI / 4 + rad / 2));
}
/** Convert a bounding box into a flyTo target that roughly fits it on screen. */
export function boundsToCenterZoom(bounds: GeoBounds): { lat: number; lng: number; zoom: number } {
const south = Math.min(bounds.south, bounds.north);
const north = Math.max(bounds.south, bounds.north);
const west = Math.min(bounds.west, bounds.east);
const east = Math.max(bounds.west, bounds.east);
const lonSpan = Math.max(east - west, 1e-6);
const mercSpan = Math.max(mercatorY(north) - mercatorY(south), 1e-6);
const zoomX = Math.log2((NOMINAL_VIEWPORT.width * 360) / (TILE_SIZE * lonSpan));
const zoomY = Math.log2((NOMINAL_VIEWPORT.height * 2 * Math.PI) / (TILE_SIZE * mercSpan));
const zoom = Math.max(MAP_MIN_ZOOM, Math.min(MAX_FIT_ZOOM, Math.min(zoomX, zoomY) - ZOOM_PADDING));
return {
lat: (south + north) / 2,
lng: (west + east) / 2,
zoom,
};
}