2026-09-21 22:41:40 +01:00
|
|
|
/**
|
2026-09-22 11:58:36 +01:00
|
|
|
* NearbySchoolsSection — the nearest eligible schools, closest first. Server
|
|
|
|
|
* component; only the carousel, the compare bar and the add-to-compare button
|
|
|
|
|
* are client-side.
|
2026-09-21 22:41:40 +01:00
|
|
|
*
|
2026-09-22 11:58:36 +01:00
|
|
|
* "Other schools nearby", not "similar" ones: the order is distance and only
|
|
|
|
|
* distance. The hard filters upstream still guarantee the set is comparable —
|
|
|
|
|
* same phase, same selectivity, mainstream never beside special — but nothing
|
|
|
|
|
* here ranks by how alike two schools are, so the heading does not say it does.
|
|
|
|
|
*
|
|
|
|
|
* The chips report what a school shares, and may be absent entirely. That is
|
|
|
|
|
* information for the reader to weigh, not a verdict this section has already
|
|
|
|
|
* reached on their behalf.
|
2026-09-21 22:41:40 +01:00
|
|
|
*
|
|
|
|
|
* There is deliberately no "how these are chosen" panel: the method is already
|
2026-10-03 21:10:36 +01:00
|
|
|
* visible in the chips and the distances. The single caption line is not a
|
|
|
|
|
* method note — it is the one thing a card cannot self-correct.
|
|
|
|
|
*
|
|
|
|
|
* Nor is there a lede: "Other primary schools near X" only restated the heading.
|
2026-09-21 22:41:40 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import Link from 'next/link';
|
2026-09-22 11:59:27 +01:00
|
|
|
import type { NearbySchool } from '@/lib/types';
|
2026-09-21 22:41:40 +01:00
|
|
|
import { schoolUrl } from '@/lib/utils';
|
|
|
|
|
import { AddToCompareButton } from './AddToCompareButton';
|
2026-09-22 11:59:27 +01:00
|
|
|
import { NearbySchoolsCarousel } from './NearbySchoolsCarousel';
|
|
|
|
|
import { NearbySchoolsCompareBar } from './NearbySchoolsCompareBar';
|
2026-09-21 22:41:40 +01:00
|
|
|
import { Section } from './sectionShared';
|
2026-09-22 11:59:27 +01:00
|
|
|
import styles from './NearbySchools.module.css';
|
2026-09-21 22:41:40 +01:00
|
|
|
|
|
|
|
|
const MINIMUM = 2;
|
|
|
|
|
|
2026-09-22 11:59:27 +01:00
|
|
|
export function shouldRenderNearby(nearby?: NearbySchool[] | null): boolean {
|
|
|
|
|
return (nearby?.length ?? 0) >= MINIMUM;
|
2026-09-21 22:41:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function metricLabel(key: string): string {
|
|
|
|
|
return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatMetric(value: number | null, key: string): string {
|
|
|
|
|
if (value == null) return 'Not published';
|
|
|
|
|
return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 11:59:27 +01:00
|
|
|
export function NearbySchoolsSection({
|
2026-09-21 22:41:40 +01:00
|
|
|
urn,
|
|
|
|
|
thisMetricValue,
|
2026-09-22 11:59:27 +01:00
|
|
|
nearby,
|
2026-09-21 22:41:40 +01:00
|
|
|
}: {
|
|
|
|
|
urn: number;
|
|
|
|
|
thisMetricValue: number | null;
|
2026-09-22 11:59:27 +01:00
|
|
|
nearby?: NearbySchool[] | null;
|
2026-09-21 22:41:40 +01:00
|
|
|
}) {
|
2026-09-22 11:59:27 +01:00
|
|
|
if (!shouldRenderNearby(nearby)) return null;
|
|
|
|
|
const schools = nearby as NearbySchool[];
|
2026-09-21 22:41:40 +01:00
|
|
|
const metricKey = schools[0].metric_key;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-22 11:58:36 +01:00
|
|
|
<Section id="nearby">
|
2026-09-22 11:59:27 +01:00
|
|
|
<NearbySchoolsCarousel
|
2026-09-21 22:41:40 +01:00
|
|
|
count={schools.length}
|
2026-09-22 11:58:36 +01:00
|
|
|
labelledBy="nearby-schools-heading"
|
2026-09-21 22:41:40 +01:00
|
|
|
header={
|
2026-10-03 21:10:36 +01:00
|
|
|
<h2 id="nearby-schools-heading" className={styles.heading}>
|
|
|
|
|
Other schools nearby
|
|
|
|
|
</h2>
|
2026-09-21 22:41:40 +01:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{schools.map((school) => (
|
|
|
|
|
<li key={school.urn} className={styles.school}>
|
|
|
|
|
<p className={styles.distance}>{school.distance_miles} miles away</p>
|
|
|
|
|
<h3 className={styles.name}>
|
|
|
|
|
<Link href={schoolUrl(school.urn, school.school_name)}>
|
|
|
|
|
{school.school_name}
|
|
|
|
|
</Link>
|
|
|
|
|
</h3>
|
|
|
|
|
<p className={styles.meta}>
|
|
|
|
|
{[school.school_type, school.age_range && `Ages ${school.age_range}`]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' · ')}
|
|
|
|
|
</p>
|
2026-09-22 11:58:36 +01:00
|
|
|
{school.shared.length > 0 && (
|
|
|
|
|
<ul className={styles.shared}>
|
|
|
|
|
{school.shared.map((label) => (
|
|
|
|
|
<li key={label} className={styles.chip}>{label}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
2026-09-21 22:41:40 +01:00
|
|
|
<div className={styles.metric}>
|
|
|
|
|
<p
|
|
|
|
|
className={
|
|
|
|
|
school.metric_value == null ? styles.valueAbsent : styles.value
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{formatMetric(school.metric_value, school.metric_key)}
|
|
|
|
|
</p>
|
|
|
|
|
<p className={styles.metricLabel}>{metricLabel(school.metric_key)}</p>
|
|
|
|
|
{thisMetricValue != null && (
|
|
|
|
|
<p className={styles.metricRef}>
|
|
|
|
|
{formatMetric(thisMetricValue, metricKey)} at this school
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<AddToCompareButton school={school} />
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
2026-09-22 11:59:27 +01:00
|
|
|
</NearbySchoolsCarousel>
|
2026-09-21 22:41:40 +01:00
|
|
|
|
2026-09-22 11:59:27 +01:00
|
|
|
<NearbySchoolsCompareBar thisUrn={urn} candidates={schools} />
|
2026-09-21 22:41:40 +01:00
|
|
|
|
|
|
|
|
{/* The one caveat the cards cannot make on their own: a reader who takes
|
|
|
|
|
"0.6 miles away" for the walk has been misled, and nothing else here
|
|
|
|
|
corrects that. */}
|
|
|
|
|
<p className={styles.caption}>
|
|
|
|
|
Distances are straight-line from this school, not road distance.
|
|
|
|
|
</p>
|
|
|
|
|
</Section>
|
|
|
|
|
);
|
|
|
|
|
}
|