2026-08-16 13:42:06 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-20 18:44:57 +01:00
|
|
|
* CutoffMapPanel — "How far away are you?"
|
2026-08-16 13:42:06 +01:00
|
|
|
*
|
2026-08-20 18:44:57 +01:00
|
|
|
* Measures a postcode against the one cut-off we publish, and will draw that
|
|
|
|
|
* cut-off as a ring around the school on request.
|
2026-08-20 14:54:02 +01:00
|
|
|
*
|
2026-08-20 18:44:57 +01:00
|
|
|
* It used to compare against every published year and show a set of shrinking
|
|
|
|
|
* rings. Earlier years are now held back as a paid feature and no longer leave
|
|
|
|
|
* the API, so this answers one question about one year — which makes the
|
|
|
|
|
* verdict sharper to state, and puts more weight on qualifying it properly,
|
|
|
|
|
* since there is no run of years left to soften a single close call.
|
|
|
|
|
*
|
|
|
|
|
* The map is not rendered until asked for: before a postcode is entered it is a
|
2026-08-20 14:54:02 +01:00
|
|
|
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
|
2026-08-20 18:44:57 +01:00
|
|
|
* height to say that. A successful check opens it automatically, because that
|
|
|
|
|
* is the point at which it starts answering something.
|
2026-08-16 13:42:06 +01:00
|
|
|
*
|
|
|
|
|
* The postcode never leaves the browser except to postcodes.io for a lat/long,
|
|
|
|
|
* and nothing is stored — this is a client-side measurement, not a lookup
|
|
|
|
|
* against the family.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
import { useState, type FormEvent } from 'react';
|
2026-08-16 13:42:06 +01:00
|
|
|
import dynamic from 'next/dynamic';
|
2026-08-20 18:44:57 +01:00
|
|
|
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
2026-08-16 13:42:06 +01:00
|
|
|
import { geocodePostcode, calculateDistance } from '@/lib/api';
|
|
|
|
|
import { isValidPostcode } from '@/lib/utils';
|
|
|
|
|
import {
|
2026-08-20 18:44:57 +01:00
|
|
|
compareToCutoff, CUTOFF_CHECK_CAVEAT,
|
|
|
|
|
type CutoffCheckResult, type CutoffVerdict,
|
2026-08-16 13:42:06 +01:00
|
|
|
} from './lastDistanceOffered';
|
|
|
|
|
import styles from './schoolSections.module.css';
|
|
|
|
|
|
|
|
|
|
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
|
|
|
|
ssr: false,
|
|
|
|
|
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Written out rather than composed from the verdict string. A computed
|
|
|
|
|
// `styles[...]` key silently yields undefined when a class is renamed, and an
|
2026-08-20 18:44:57 +01:00
|
|
|
// unstyled "outside" result would look exactly like an "inside" one.
|
2026-08-16 13:42:06 +01:00
|
|
|
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
|
2026-08-20 18:44:57 +01:00
|
|
|
inside: styles.cutoffResultInside,
|
|
|
|
|
outside: styles.cutoffResultOutside,
|
|
|
|
|
'too-close': styles.cutoffResultTooClose,
|
2026-08-16 13:42:06 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function CutoffMapPanel({
|
|
|
|
|
schoolInfo,
|
2026-08-20 18:44:57 +01:00
|
|
|
cutoff,
|
2026-08-16 13:42:06 +01:00
|
|
|
}: {
|
|
|
|
|
schoolInfo: School;
|
2026-08-20 18:44:57 +01:00
|
|
|
cutoff: SchoolAdmissionDistance;
|
2026-08-16 13:42:06 +01:00
|
|
|
}) {
|
|
|
|
|
const [postcode, setPostcode] = useState('');
|
|
|
|
|
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
|
|
|
|
|
const [result, setResult] = useState<CutoffCheckResult | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
2026-08-20 14:54:02 +01:00
|
|
|
const [mapOpen, setMapOpen] = useState(false);
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
const lat = schoolInfo.latitude;
|
|
|
|
|
const lng = schoolInfo.longitude;
|
2026-08-20 18:44:57 +01:00
|
|
|
const cutoffM = cutoff.distance_m;
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
// Without coordinates there is nothing to measure against and nothing to draw.
|
|
|
|
|
if (lat == null || lng == null || cutoffM == null) return null;
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
const onCheck = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const value = postcode.trim();
|
|
|
|
|
if (!value) return;
|
|
|
|
|
|
|
|
|
|
if (!isValidPostcode(value)) {
|
|
|
|
|
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
|
|
|
|
|
setResult(null);
|
|
|
|
|
setHome(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const point = await geocodePostcode(value);
|
|
|
|
|
if (!point) {
|
|
|
|
|
setError('We could not find that postcode. Check it and try again.');
|
|
|
|
|
setResult(null);
|
|
|
|
|
setHome(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setHome({ lat: point.latitude, lng: point.longitude });
|
2026-08-20 14:54:02 +01:00
|
|
|
setMapOpen(true);
|
2026-08-16 13:42:06 +01:00
|
|
|
// calculateDistance returns kilometres; everything here is metres.
|
|
|
|
|
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
|
2026-08-20 18:44:57 +01:00
|
|
|
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
|
2026-08-16 13:42:06 +01:00
|
|
|
} catch {
|
|
|
|
|
setError('Something went wrong looking up that postcode. Try again in a moment.');
|
|
|
|
|
setResult(null);
|
|
|
|
|
setHome(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-20 18:44:57 +01:00
|
|
|
<div className={styles.cutoffCheck}>
|
|
|
|
|
<p className={styles.cutoffCheckSub}>
|
|
|
|
|
Straight-line distance from your postcode, compared with the September{' '}
|
|
|
|
|
{cutoff.year} cut-off. Not stored.
|
|
|
|
|
</p>
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
|
|
|
|
|
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
|
|
|
|
|
<input
|
|
|
|
|
id="cutoff-postcode"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="text"
|
|
|
|
|
autoComplete="postal-code"
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
placeholder="e.g. SE23 3NA"
|
|
|
|
|
value={postcode}
|
|
|
|
|
onChange={(e) => setPostcode(e.target.value)}
|
|
|
|
|
className={styles.cutoffCheckInput}
|
|
|
|
|
/>
|
|
|
|
|
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
|
|
|
|
|
{busy ? 'Checking…' : 'Check'}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
{result && (
|
|
|
|
|
<div
|
|
|
|
|
className={`${styles.cutoffCheckResult} ${VERDICT_CLASS[result.verdict]}`}
|
|
|
|
|
role="status"
|
|
|
|
|
>
|
|
|
|
|
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
|
|
|
|
|
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
{mapOpen ? (
|
|
|
|
|
<div className={styles.cutoffMapReveal}>
|
|
|
|
|
<div className={styles.cutoffMapFigure}>
|
|
|
|
|
<CutoffMap
|
|
|
|
|
lat={lat}
|
|
|
|
|
lng={lng}
|
|
|
|
|
rings={[{ year: cutoff.year, radiusM: cutoffM, latest: true }]}
|
|
|
|
|
home={home}
|
|
|
|
|
interactive={false}
|
|
|
|
|
/>
|
2026-08-16 13:42:06 +01:00
|
|
|
</div>
|
2026-08-20 18:44:57 +01:00
|
|
|
<ul className={styles.cutoffMapLegend}>
|
|
|
|
|
<li>
|
|
|
|
|
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
|
|
|
|
September {cutoff.year} cut-off
|
|
|
|
|
</li>
|
|
|
|
|
{home && (
|
2026-08-20 14:54:02 +01:00
|
|
|
<li>
|
2026-08-20 18:44:57 +01:00
|
|
|
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
|
|
|
|
|
Your postcode
|
2026-08-20 14:54:02 +01:00
|
|
|
</li>
|
2026-08-20 18:44:57 +01:00
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.cutoffMapToggle}
|
|
|
|
|
onClick={() => setMapOpen(true)}
|
|
|
|
|
>
|
|
|
|
|
Show this distance on a map
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-08-20 14:54:02 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
|
2026-08-16 13:42:06 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|