473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { tDynamic } from '../../i18n';
|
|
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
|
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
|
import { SubNav } from '../ui/SubNav';
|
|
|
|
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
|
|
|
interface DataSourceDef {
|
|
id: string;
|
|
url: string;
|
|
license: string;
|
|
optOutUrl?: string;
|
|
}
|
|
|
|
const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
|
{
|
|
id: 'price-paid',
|
|
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'epc',
|
|
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
|
license: 'Open Government Licence v3.0',
|
|
optOutUrl:
|
|
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
|
},
|
|
{
|
|
id: 'nspl',
|
|
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'iod',
|
|
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'ethnicity',
|
|
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
|
|
{
|
|
id: 'osm-pois',
|
|
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
|
license: 'Open Data Commons Open Database License (ODbL)',
|
|
},
|
|
{
|
|
id: 'geolytix-retail-points',
|
|
url: 'https://geolytix.com/blog/supermarket-retail-points/',
|
|
license: 'GEOLYTIX Open Data License',
|
|
},
|
|
{
|
|
id: 'os-open-greenspace',
|
|
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'forest-research-tow',
|
|
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'naptan',
|
|
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'noise',
|
|
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'ofsted',
|
|
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'broadband',
|
|
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'council-tax',
|
|
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'ons-rental',
|
|
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
|
license: 'Open Government Licence v3.0',
|
|
},
|
|
{
|
|
id: 'election-results',
|
|
url: 'https://electionresults.parliament.uk/general-elections/6',
|
|
license: 'Open Parliament Licence v3.0',
|
|
},
|
|
];
|
|
|
|
// Maps data source id → [nameKey, originKey, useKey] in en.ts learnPage section
|
|
const DS_KEYS: Record<string, [string, string, string]> = {
|
|
'price-paid': [
|
|
'learnPage.dsPricePaidName',
|
|
'learnPage.dsPricePaidOrigin',
|
|
'learnPage.dsPricePaidUse',
|
|
],
|
|
epc: ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
|
|
nspl: ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
|
|
iod: ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
|
|
ethnicity: [
|
|
'learnPage.dsEthnicityName',
|
|
'learnPage.dsEthnicityOrigin',
|
|
'learnPage.dsEthnicityUse',
|
|
],
|
|
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
|
|
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
|
'geolytix-retail-points': [
|
|
'learnPage.dsGeolytixRetailName',
|
|
'learnPage.dsGeolytixRetailOrigin',
|
|
'learnPage.dsGeolytixRetailUse',
|
|
],
|
|
'os-open-greenspace': [
|
|
'learnPage.dsGreenspaceName',
|
|
'learnPage.dsGreenspaceOrigin',
|
|
'learnPage.dsGreenspaceUse',
|
|
],
|
|
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
|
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
|
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
|
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
|
broadband: [
|
|
'learnPage.dsBroadbandName',
|
|
'learnPage.dsBroadbandOrigin',
|
|
'learnPage.dsBroadbandUse',
|
|
],
|
|
'council-tax': [
|
|
'learnPage.dsCouncilTaxName',
|
|
'learnPage.dsCouncilTaxOrigin',
|
|
'learnPage.dsCouncilTaxUse',
|
|
],
|
|
'ons-rental': ['learnPage.dsRentalName', 'learnPage.dsRentalOrigin', 'learnPage.dsRentalUse'],
|
|
'election-results': [
|
|
'learnPage.dsElectionName',
|
|
'learnPage.dsElectionOrigin',
|
|
'learnPage.dsElectionUse',
|
|
],
|
|
};
|
|
|
|
function FAQItemCard({ question, answer }: { question: string; answer: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700">
|
|
<button
|
|
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
<span className="font-medium text-warm-900 dark:text-warm-100">{question}</span>
|
|
<ChevronIcon
|
|
direction="down"
|
|
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div className="px-5 pb-4">
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{answer}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function LearnPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const [tab, setTab] = useState<LearnTab>('faq');
|
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const seoPageLinks = useMemo(
|
|
() =>
|
|
getLocalizedSeoPages(i18n.language).map((page) => ({
|
|
path: page.path,
|
|
eyebrow: page.eyebrow,
|
|
title: page.title,
|
|
description: page.metaDescription,
|
|
})),
|
|
[i18n.language]
|
|
);
|
|
|
|
const LEARN_TABS = [
|
|
{ key: 'faq', label: t('learnPage.faq') },
|
|
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
|
{ key: 'articles', label: t('learnPage.articles') },
|
|
{ key: 'support', label: t('learnPage.support') },
|
|
];
|
|
|
|
const FAQ_SECTIONS = [
|
|
{
|
|
title: t('learnPage.faqFindingTitle'),
|
|
items: [
|
|
{ question: t('learnPage.faqFinding1Q'), answer: t('learnPage.faqFinding1A') },
|
|
{ question: t('learnPage.faqFinding3Q'), answer: t('learnPage.faqFinding3A') },
|
|
],
|
|
},
|
|
{
|
|
title: t('learnPage.faqCommuteTitle'),
|
|
items: [
|
|
{ question: t('learnPage.faqCommute1Q'), answer: t('learnPage.faqCommute1A') },
|
|
{ question: t('learnPage.faqCommute2Q'), answer: t('learnPage.faqCommute2A') },
|
|
{ question: t('learnPage.faqCommute3Q'), answer: t('learnPage.faqCommute3A') },
|
|
],
|
|
},
|
|
{
|
|
title: t('learnPage.faqBudgetTitle'),
|
|
items: [
|
|
{ question: t('learnPage.faqBudget1Q'), answer: t('learnPage.faqBudget1A') },
|
|
{ question: t('learnPage.faqBudget2Q'), answer: t('learnPage.faqBudget2A') },
|
|
],
|
|
},
|
|
{
|
|
title: t('learnPage.faqTipsTitle'),
|
|
items: [
|
|
{ question: t('learnPage.faqTips1Q'), answer: t('learnPage.faqTips1A') },
|
|
{ question: t('learnPage.faqTips2Q'), answer: t('learnPage.faqTips2A') },
|
|
{ question: t('learnPage.faqTips3Q'), answer: t('learnPage.faqTips3A') },
|
|
],
|
|
},
|
|
{
|
|
title: t('learnPage.faqDueDiligenceTitle'),
|
|
items: [
|
|
{ question: t('learnPage.faqDueDiligence1Q'), answer: t('learnPage.faqDueDiligence1A') },
|
|
{ question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
|
|
],
|
|
},
|
|
{
|
|
title: t('learnPage.faqPrivacyTitle'),
|
|
items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
|
|
},
|
|
{
|
|
title: t('learnPage.faqPricingTitle'),
|
|
items: [{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') }],
|
|
},
|
|
];
|
|
|
|
useEffect(() => {
|
|
function handleHash() {
|
|
const hash = window.location.hash.replace('#', '');
|
|
if (hash === 'faq') {
|
|
setTab('faq');
|
|
setHighlightedId(null);
|
|
} else if (hash === 'articles') {
|
|
setTab('articles');
|
|
setHighlightedId(null);
|
|
} else if (hash === 'support') {
|
|
setTab('support');
|
|
setHighlightedId(null);
|
|
} else if (hash && DATA_SOURCE_DEFS.some((s) => s.id === hash)) {
|
|
setTab('data-sources');
|
|
setHighlightedId(hash);
|
|
setTimeout(() => {
|
|
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}, 100);
|
|
} else {
|
|
setHighlightedId(null);
|
|
}
|
|
}
|
|
handleHash();
|
|
window.addEventListener('hashchange', handleHash);
|
|
return () => window.removeEventListener('hashchange', handleHash);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollContainerRef.current?.scrollTo(0, 0);
|
|
}, [tab]);
|
|
|
|
const switchTab = (key: string) => {
|
|
setTab(key as LearnTab);
|
|
setHighlightedId(null);
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
|
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
|
|
|
|
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
|
|
{tab === 'data-sources' ? (
|
|
<>
|
|
<div className="flex-1">
|
|
<div className="max-w-5xl mx-auto px-6 py-6">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
{t('learnPage.dataSources')}
|
|
</h1>
|
|
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
|
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
|
</p>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{DATA_SOURCE_DEFS.map((source) => {
|
|
const keys = DS_KEYS[source.id];
|
|
const [nameKey, originKey, useKey] = keys;
|
|
return (
|
|
<div
|
|
key={source.id}
|
|
id={source.id}
|
|
ref={(el) => {
|
|
cardRefs.current[source.id] = el;
|
|
}}
|
|
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
|
highlightedId === source.id
|
|
? 'border-teal-400 ring-2 ring-teal-400'
|
|
: 'border-warm-200 dark:border-warm-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-4 mb-2">
|
|
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
|
{tDynamic(nameKey)}
|
|
</h2>
|
|
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
|
{source.license}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
|
{t('learnPage.source')} {tDynamic(originKey)}
|
|
</p>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
|
{tDynamic(useKey)}
|
|
</p>
|
|
<a
|
|
href={source.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
|
|
>
|
|
{source.url}
|
|
</a>
|
|
{source.optOutUrl && (
|
|
<div className="mt-2">
|
|
<a
|
|
href={source.optOutUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
|
>
|
|
{t('learnPage.optOut')}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
|
<div className="max-w-5xl mx-auto">
|
|
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
|
{t('learnPage.attribution')}
|
|
</h2>
|
|
<ul className="space-y-1.5 text-sm">
|
|
<li>{t('learnPage.attrLandRegistry')}</li>
|
|
<li>
|
|
{t('learnPage.attrOgl')}{' '}
|
|
<a
|
|
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
|
>
|
|
{t('learnPage.attrOglLink')}
|
|
</a>
|
|
.
|
|
</li>
|
|
<li>{t('learnPage.attrOs')}</li>
|
|
<li>{t('learnPage.attrTfl')}</li>
|
|
<li>
|
|
{t('learnPage.attrOsm')}{' '}
|
|
<a
|
|
href="https://www.openstreetmap.org/copyright"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
|
>
|
|
{t('learnPage.attrOsmContrib')}
|
|
</a>
|
|
, {t('learnPage.attrOsmLicense')}{' '}
|
|
<a
|
|
href="https://opendatacommons.org/licenses/odbl/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
|
>
|
|
{t('learnPage.attrOsmLicenseLink')}
|
|
</a>
|
|
.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</footer>
|
|
</>
|
|
) : tab === 'faq' ? (
|
|
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
{t('learnPage.faq')}
|
|
</h1>
|
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
|
<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} question={item.question} answer={item.answer} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : tab === 'articles' ? (
|
|
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
{t('learnPage.articles')}
|
|
</h1>
|
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{seoPageLinks.map((link) => (
|
|
<a
|
|
key={link.path}
|
|
href={link.path}
|
|
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
|
>
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
|
{link.eyebrow}
|
|
</div>
|
|
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
|
{link.title}
|
|
</h2>
|
|
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
|
{link.description}
|
|
</p>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
{t('learnPage.support')}
|
|
</h1>
|
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
|
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
|
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
|
<a
|
|
href="mailto:support@perfect-postcode.co.uk"
|
|
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
|
>
|
|
support@perfect-postcode.co.uk
|
|
</a>
|
|
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
|
{t('accountPage.responseTime')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|