feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
- URLs now /school/138267-school-name instead of /school/138267 - Bare URN URLs redirect to canonical slug (backward compat) - Remove overflow-x:hidden that broke sticky tab nav on secondary pages - ComparisonToast starts collapsed — user must click to open Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,25 @@
|
||||
/**
|
||||
* Individual School Page (SSR)
|
||||
* Dynamic route for school details with full SEO optimization
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails } from '@/lib/api';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface SchoolPageProps {
|
||||
params: Promise<{ urn: string }>;
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
||||
const { urn: urnString } = await params;
|
||||
const urn = parseInt(urnString);
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
@@ -27,6 +29,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
const data = await fetchSchoolDetails(urn);
|
||||
const { school_info } = data;
|
||||
|
||||
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
||||
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
||||
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null);
|
||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||
@@ -44,7 +47,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk/school/${urn}`,
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
},
|
||||
twitter: {
|
||||
@@ -53,7 +56,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://schoolcompare.co.uk/school/${urn}`,
|
||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -67,11 +70,11 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const { urn: urnString } = await params;
|
||||
const urn = parseInt(urnString);
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
// Validate URN format
|
||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -86,6 +89,12 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
if (slug !== canonicalSlug) {
|
||||
redirect(`/school/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { MetadataRoute } from 'next';
|
||||
import { fetchSchools } from '@/lib/api';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
|
||||
const BASE_URL = 'https://schoolcompare.co.uk';
|
||||
|
||||
@@ -39,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
});
|
||||
|
||||
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
|
||||
url: `${BASE_URL}/school/${school.urn}`,
|
||||
url: `${BASE_URL}${schoolUrl(school.urn, school.school_name)}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
|
||||
export function ComparisonToast() {
|
||||
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
@@ -305,7 +305,7 @@ export function ComparisonView({
|
||||
×
|
||||
</button>
|
||||
<h2 className={styles.schoolName}>
|
||||
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||
</h2>
|
||||
<div className={styles.schoolMeta}>
|
||||
{school.local_authority && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EmptyState } from './EmptyState';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
interface HomeViewProps {
|
||||
@@ -316,7 +317,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
<div className={styles.compactItem}>
|
||||
<div className={styles.compactItemContent}>
|
||||
<div className={styles.compactItemHeader}>
|
||||
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.compactItemName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.distance !== undefined && school.distance !== null && (
|
||||
@@ -352,7 +353,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
>
|
||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
|
||||
// Fix for default marker icons in Next.js
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@@ -60,7 +61,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
||||
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
|
||||
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
|
||||
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
||||
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, schoolUrl } from '@/lib/utils';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import styles from './RankingsView.module.css';
|
||||
|
||||
@@ -267,7 +267,7 @@ export function RankingsView({
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.schoolCell}>
|
||||
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
|
||||
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className={styles.schoolLink}>
|
||||
{ranking.school_name}
|
||||
</a>
|
||||
</td>
|
||||
@@ -277,7 +277,7 @@ export function RankingsView({
|
||||
<strong>{displayValue}</strong>
|
||||
</td>
|
||||
<td className={styles.actionCell}>
|
||||
<a href={`/school/${ranking.urn}`} className="btn btn-tertiary btn-sm">View</a>
|
||||
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className="btn btn-tertiary btn-sm">View</a>
|
||||
<button
|
||||
onClick={() => handleAddToCompare(ranking)}
|
||||
disabled={alreadyInComparison}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getTrendColor, schoolUrl } from '@/lib/utils';
|
||||
import styles from './SchoolCard.module.css';
|
||||
|
||||
interface SchoolCardProps {
|
||||
@@ -25,7 +25,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
||||
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>
|
||||
<Link href={`/school/${school.urn}`}>
|
||||
<Link href={{schoolUrl(school.urn, school.school_name)}}>
|
||||
{school.school_name}
|
||||
</Link>
|
||||
</h3>
|
||||
@@ -146,7 +146,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link href={`/school/${school.urn}`} className="btn btn-primary">
|
||||
<Link href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-primary">
|
||||
View Details
|
||||
</Link>
|
||||
{onAddToCompare && (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, calculateTrend, schoolUrl } from '@/lib/utils';
|
||||
import { progressBand } from '@/lib/metrics';
|
||||
import styles from './SchoolRow.module.css';
|
||||
|
||||
@@ -61,7 +61,7 @@ export function SchoolRow({
|
||||
|
||||
{/* Line 1: School name + Ofsted badge */}
|
||||
<div className={styles.line1}>
|
||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.ofsted_grade && (
|
||||
@@ -155,7 +155,7 @@ export function SchoolRow({
|
||||
|
||||
{/* Right: actions, vertically centred */}
|
||||
<div className={styles.rowActions}>
|
||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
{(onAddToCompare || onRemoveFromCompare) && (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────── */
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
import styles from './SecondarySchoolRow.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
@@ -73,7 +73,7 @@ export function SecondarySchoolRow({
|
||||
|
||||
{/* Line 1: School name + Ofsted badge */}
|
||||
<div className={styles.line1}>
|
||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.ofsted_grade && (
|
||||
@@ -155,7 +155,7 @@ export function SecondarySchoolRow({
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className={styles.rowActions}>
|
||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
||||
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
{(onAddToCompare || onRemoveFromCompare) && (
|
||||
|
||||
@@ -20,6 +20,28 @@ export function slugify(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a school URL path: /school/123456-school-name (capped at ~80 chars total)
|
||||
*/
|
||||
const MAX_SLUG_LENGTH = 60;
|
||||
|
||||
export function schoolUrl(urn: number, schoolName?: string): string {
|
||||
if (!schoolName) return `/school/${urn}`;
|
||||
let slug = slugify(schoolName);
|
||||
if (slug.length > MAX_SLUG_LENGTH) {
|
||||
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, '');
|
||||
}
|
||||
return `/school/${urn}-${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the URN from a school slug (e.g. "138267-some-school-name" → 138267)
|
||||
*/
|
||||
export function parseSchoolSlug(slug: string): number | null {
|
||||
const match = slug.match(/^(\d{6})/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user