perfect-postcode/frontend/src/components/learn/LearnPage.tsx
2026-05-14 20:42:48 +01:00

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>
);
}