The home page caught every fetch failure and rendered its empty state, so a backend outage looked like a site with no schools in it. School pages turned any error into notFound(), which told visitors — and crawlers — that a real school had ceased to exist. Place fetches did the same by returning [] and null. Failures now reach a retryable error boundary; only a genuine 404 still calls notFound(). "Load more" and the map fetch resolved against whatever state existed when they returned, so results from an abandoned search appended themselves to the new ones. Each fetch now carries an AbortController and checks that its search scope is still current before touching state. The map only records its cache key on success, so a failed load retries instead of pinning the stale marker set. Jest ignored .next/, whose build output otherwise shadowed real suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
/**
|
|
* Home Page (SSR)
|
|
* Main landing page with school search and browsing
|
|
*/
|
|
|
|
import { absoluteUrl } from '@/lib/site';
|
|
import type { Metadata } from 'next';
|
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
|
import { formatAcademicYear } from '@/lib/utils';
|
|
import { HomeView } from '@/components/HomeView';
|
|
import { getFlags } from '@/lib/flags';
|
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
|
import { EditorialSection } from '@/components/EditorialSection';
|
|
|
|
interface HomePageProps {
|
|
searchParams: Promise<{
|
|
search?: string;
|
|
local_authority?: string;
|
|
school_type?: string;
|
|
phase?: string;
|
|
page?: string;
|
|
postcode?: string;
|
|
radius?: string;
|
|
sort?: string;
|
|
gender?: string;
|
|
admissions_policy?: string;
|
|
has_sixth_form?: string;
|
|
}>;
|
|
}
|
|
|
|
/*
|
|
* `title` was the bare string 'Home', which is what the browser tab, the
|
|
* bookmark and the search result all read. `absolute` opts out of the root
|
|
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
|
* saying the brand twice.
|
|
*/
|
|
export const metadata: Metadata = {
|
|
/*
|
|
* Intent in the title, differentiator in the description.
|
|
*
|
|
* These queries are owned by the DfE's own "Compare school performance"
|
|
* service, and the old title — brand first, then a near-paraphrase of that
|
|
* service's name — gave a searcher no reason to pick us over it. It drew
|
|
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
|
* neighbourhood, so the ranking was never the problem.
|
|
*
|
|
* The title now matches what people type. The description carries the one
|
|
* fact gov.uk does not publish: how close you had to live to get a place.
|
|
*/
|
|
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
|
description:
|
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
|
// This page reads eleven search params. They filter a result set; they do
|
|
// not make a new document. Collapsing every combination onto "/" stops the
|
|
// homepage competing with itself for its own head terms.
|
|
alternates: { canonical: absoluteUrl('/') },
|
|
};
|
|
|
|
// The page reads searchParams, which makes rendering dynamic by default.
|
|
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
|
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
|
|
|
export default async function HomePage({ searchParams }: HomePageProps) {
|
|
// Await search params (Next.js 15 requirement)
|
|
const params = await searchParams;
|
|
|
|
// Server-read: no flag value reaches the browser bundle. Threaded down to
|
|
// both FilterBar instances via HomeView.
|
|
const flags = await getFlags();
|
|
const autosuggest = flags.school_autosuggest === true;
|
|
|
|
// Parse search params
|
|
const page = parseInt(params.page || '1');
|
|
const radius = params.radius ? parseFloat(params.radius) : undefined;
|
|
|
|
// Check if user has performed a search
|
|
const hasSearchParams = !!(
|
|
params.search ||
|
|
params.local_authority ||
|
|
params.school_type ||
|
|
params.phase ||
|
|
params.postcode ||
|
|
params.gender ||
|
|
params.admissions_policy ||
|
|
params.has_sixth_form
|
|
);
|
|
|
|
// Failures propagate to the retryable error boundary.
|
|
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
|
|
|
|
// Only fetch schools if there are search parameters
|
|
let schoolsData;
|
|
if (hasSearchParams) {
|
|
schoolsData = await fetchSchools({
|
|
search: params.search,
|
|
local_authority: params.local_authority,
|
|
school_type: params.school_type,
|
|
phase: params.phase,
|
|
postcode: params.postcode,
|
|
radius,
|
|
page,
|
|
page_size: 50,
|
|
gender: params.gender,
|
|
admissions_policy: params.admissions_policy,
|
|
has_sixth_form: params.has_sixth_form,
|
|
});
|
|
} else {
|
|
// Empty state by default
|
|
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
|
}
|
|
|
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
|
// `unique_schools`, not `total_schools` — the latter is not a field this
|
|
// endpoint returns, and reading it silently yielded null on every request.
|
|
const total = dataInfo?.unique_schools ?? null;
|
|
const years = dataInfo?.years_available ?? [];
|
|
return (
|
|
<HomeView
|
|
autosuggest={autosuggest}
|
|
initialSchools={schoolsData}
|
|
filters={resolvedFilters}
|
|
totalSchools={total}
|
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
|
editorial={hasSearchParams ? null : (
|
|
<EditorialSection
|
|
totalSchools={total}
|
|
localAuthorityCount={resolvedFilters.local_authorities.length}
|
|
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
|
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|