feat(admissions): add admissions guide page and homepage countdown strip
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

- New /admissions route with AdmissionsView client component
- Live countdowns (days until) to Primary/Secondary deadlines and Offer Days
- Step-by-step timelines for both tracks with highlighted milestone rows
- Tips section covering equal preference rule, late applications, waiting lists
- Homepage countdown strip (4 cards) between discovery chips and how-it-works
- Admissions nav link and footer link added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-04-15 17:00:21 +01:00
parent 3327728df0
commit f6b9d650f8
7 changed files with 971 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
import { AdmissionsView } from '@/components/AdmissionsView';
export const dynamic = 'force-static';
export const metadata: Metadata = {
title: 'School Admissions Guide',
description:
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
};
export default function AdmissionsPage() {
return <AdmissionsView />;
}
@@ -0,0 +1,426 @@
.page {
max-width: 900px;
margin: 0 auto;
padding: 0 1.25rem 4rem;
}
/* ─── Hero ───────────────────────────────────────────── */
.hero {
text-align: center;
padding: 3rem 0 2rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-teal, #2d7d7d);
background: rgba(45, 125, 125, 0.1);
padding: 0.3rem 0.7rem;
border-radius: 999px;
margin-bottom: 1rem;
}
.eyebrowDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-teal, #2d7d7d);
}
.heroTitle {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 2.75rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.015em;
color: var(--text-primary, #1a1612);
margin-bottom: 0.85rem;
}
.heroSub {
font-size: 1.05rem;
color: var(--text-secondary, #5c564d);
max-width: 640px;
margin: 0 auto;
line-height: 1.55;
}
/* ─── Countdown strip ────────────────────────────────── */
.countdownSection {
padding: 0 0 2.5rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
margin-bottom: 2.5rem;
}
.stripHeader {
margin-bottom: 0.85rem;
}
.stripLabel {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.countdownRail {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.chip {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1rem 1.1rem 0.9rem;
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
display: flex;
flex-direction: column;
gap: 0.2rem;
position: relative;
overflow: hidden;
}
.chip::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 12px 12px 0 0;
}
.chipDeadline::before { background: var(--accent-coral, #e07256); }
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
.chipUrgent {
border-color: rgba(224, 114, 86, 0.4);
background: rgba(224, 114, 86, 0.04);
}
.chipTrack {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.15rem;
}
.chipTrackDeadline { color: var(--accent-coral, #e07256); }
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
.chipTrackDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.chipDays {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 2.6rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
.chipDeadline .chipDays,
.chipUrgent .chipDays {
color: var(--accent-coral-dark, #c45a3f);
}
.chipOffer .chipDays {
color: var(--accent-teal, #2d7d7d);
}
.chipDaysUnit {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted, #6d685f);
margin-left: 0.2rem;
vertical-align: bottom;
line-height: 2;
}
.chipMilestone {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
line-height: 1.25;
margin-top: 0.1rem;
}
.chipDate {
font-size: 0.75rem;
color: var(--text-muted, #6d685f);
margin-top: 0.05rem;
}
/* ─── Track (Secondary / Primary) ────────────────────── */
.track {
margin-bottom: 3rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 14px;
overflow: hidden;
}
.trackHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.75rem 2rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
flex-wrap: wrap;
}
.trackHeaderLeft {
flex: 1;
min-width: 0;
}
.trackKicker {
display: block;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-coral, #e07256);
margin-bottom: 0.35rem;
}
.trackTitle {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.4rem;
line-height: 1.2;
}
.trackSub {
font-size: 0.9rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
.trackDates {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
text-align: right;
}
.trackDateRow {
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.trackDateLabel {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.trackDateVal {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* ─── Timeline ───────────────────────────────────────── */
.timeline {
list-style: none;
padding: 1.5rem 2rem 1.75rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.step {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.stepDotCol {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 20px;
padding-top: 0.15rem;
}
.stepDot {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--bg-card, #fff);
border: 2px solid var(--border-color, #e5dfd5);
flex-shrink: 0;
transition: border-color 0.2s ease, background 0.2s ease;
}
.stepDeadline .stepDot {
background: rgba(224, 114, 86, 0.15);
border-color: var(--accent-coral, #e07256);
}
.stepOffer .stepDot {
background: rgba(45, 125, 125, 0.15);
border-color: var(--accent-teal, #2d7d7d);
}
.stepLine {
width: 2px;
flex: 1;
min-height: 24px;
background: var(--border-color, #e5dfd5);
margin: 4px 0;
}
.stepContent {
padding-bottom: 1.5rem;
flex: 1;
min-width: 0;
}
.step:last-child .stepContent {
padding-bottom: 0;
}
.stepDate {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
margin-bottom: 0.2rem;
}
.stepDeadline .stepDate { color: var(--accent-coral, #e07256); }
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
.stepTitle {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.3rem;
line-height: 1.3;
}
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
.stepBody {
font-size: 0.88rem;
color: var(--text-secondary, #5c564d);
line-height: 1.55;
margin: 0;
}
/* ─── Tips ───────────────────────────────────────────── */
.tips {
margin-top: 1rem;
}
.tipsHeading {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 1.25rem;
}
.tipsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.tipCard {
background: var(--bg-secondary, #f3ede4);
border-radius: 12px;
padding: 1.25rem;
}
.tipNumber {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.75rem;
font-weight: 700;
color: var(--border-color, #e5dfd5);
line-height: 1;
margin-bottom: 0.6rem;
}
.tipHeading {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.4rem;
line-height: 1.3;
}
.tipBody {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
line-height: 1.55;
margin: 0;
}
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.heroTitle {
font-size: 2rem;
}
.countdownRail {
grid-template-columns: repeat(2, 1fr);
}
.trackHeader {
flex-direction: column;
padding: 1.25rem 1.25rem 1rem;
}
.trackDates {
text-align: left;
flex-direction: row;
gap: 1.5rem;
}
.timeline {
padding: 1.25rem;
}
.tipsGrid {
grid-template-columns: 1fr;
}
}
+291
View File
@@ -0,0 +1,291 @@
'use client';
import { useState, useEffect } from 'react';
import styles from './AdmissionsView.module.css';
/* ─── Date helpers ─────────────────────────────────────── */
function daysUntil(month: number, day: number): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target <= today) target = new Date(y + 1, month - 1, day);
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
}
function nextDate(month: number, day: number): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
const target = new Date(y, month - 1, day);
if (target <= today) return new Date(y + 1, month - 1, day);
return target;
}
function fmtDate(month: number, day: number): string {
return nextDate(month, day).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
/* ─── Data ─────────────────────────────────────────────── */
interface Chip {
type: 'deadline' | 'offer';
track: string;
milestone: string;
month: number;
day: number;
}
const CHIPS: Chip[] = [
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
];
interface Step {
date?: string;
title: string;
body: string;
highlight?: 'deadline' | 'offer';
}
const SECONDARY_STEPS: Step[] = [
{
title: 'Check entry criteria',
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely. Use school detail pages on SchoolCompare for admissions history.',
},
{
date: 'September',
title: 'Portal opens',
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
},
{
date: '31 October',
title: 'Application deadline',
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
highlight: 'deadline',
},
{
date: '1 March',
title: 'National Offer Day',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
highlight: 'offer',
},
{
date: '~15 March',
title: 'Accept or decline',
body: 'Respond by the deadline your council gives — typically around 15 March. Accepting does not prevent you from keeping a place on a waiting list for a preferred school.',
},
{
title: 'Appeals',
body: 'If unsuccessful, you can appeal within 20 school days of the refusal letter. Secondary appeals consider whether prejudice to the school outweighs your case — success rates vary.',
},
];
const PRIMARY_STEPS: Step[] = [
{
title: 'Research entry criteria',
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
},
{
date: 'September',
title: 'Portal opens',
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
},
{
date: '15 January',
title: 'Application deadline',
body: 'List up to 36 schools (the number varies by council) in genuine preference order. The equal preference rule means all preferences are considered before any offers are made.',
highlight: 'deadline',
},
{
date: '16 April',
title: 'National Offer Day',
body: 'Results are published online. Reception offers are sent on 16 April (or the next working day if that falls on a weekend or bank holiday).',
highlight: 'offer',
},
{
date: '~1 May',
title: 'Accept or decline',
body: 'Respond by your council\'s deadline, typically around 1 May. Accepting secures the place while you wait to see if a preferred school\'s waiting list moves.',
},
{
title: 'Appeals',
body: 'Infant class-size appeals (Reception to Year 2) have a very narrow legal test and a low success rate. For Year 3+, appeals follow the same process as secondary.',
},
];
interface Tip {
heading: string;
body: string;
}
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
},
{
heading: 'Late applications go to the back',
body: 'Submit before the deadline even if your child does not turn the required age until later in the year. Late applicants are only considered after all on-time applications.',
},
{
heading: 'Waiting lists',
body: 'You can go on waiting lists for multiple schools simultaneously. Lists are ordered by admissions criteria, not when you joined. They can move significantly over the summer.',
},
];
/* ─── Component ────────────────────────────────────────── */
export function AdmissionsView() {
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
useEffect(() => {
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
}, []);
return (
<div className={styles.page}>
{/* Hero */}
<section className={styles.hero}>
<span className={styles.eyebrow}>
<span className={styles.eyebrowDot} aria-hidden="true" />
England · Primary &amp; Secondary
</span>
<h1 className={styles.heroTitle}>School Admissions Guide</h1>
<p className={styles.heroSub}>
Everything parents need to know about applying for a school place in England from opening dates to National Offer Day, with live countdowns to every key milestone.
</p>
</section>
{/* Countdown strip */}
<section className={styles.countdownSection}>
<div className={styles.stripHeader}>
<span className={styles.stripLabel}>Days until next milestone</span>
</div>
<div className={styles.countdownRail}>
{CHIPS.map((chip, i) => {
const days = chipDays[i];
const isUrgent = days !== null && days <= 14;
const chipClass = [
styles.chip,
chip.type === 'deadline' ? styles.chipDeadline : styles.chipOffer,
isUrgent ? styles.chipUrgent : '',
].filter(Boolean).join(' ');
return (
<div key={chip.milestone} className={chipClass}>
<span className={[styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer].join(' ')}>
<span className={styles.chipTrackDot} aria-hidden="true" />
{chip.track}
</span>
<div>
<span className={styles.chipDays}>{days ?? '—'}</span>
{days !== null && <span className={styles.chipDaysUnit}>days</span>}
</div>
<div className={styles.chipMilestone}>{chip.milestone}</div>
<div className={styles.chipDate}>{days !== null ? fmtDate(chip.month, chip.day) : ''}</div>
</div>
);
})}
</div>
</section>
{/* Secondary track */}
<section className={styles.track}>
<div className={styles.trackHeader}>
<div className={styles.trackHeaderLeft}>
<span className={styles.trackKicker}>Year 7 entry</span>
<h2 className={styles.trackTitle}>Secondary school admissions</h2>
<p className={styles.trackSub}>For children starting secondary school (Year 7) in September. Applications are submitted in the autumn of Year 6.</p>
</div>
<div className={styles.trackDates}>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Deadline</span>
<span className={styles.trackDateVal}>{fmtDate(10, 31)}</span>
</div>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Offer Day</span>
<span className={styles.trackDateVal}>{fmtDate(3, 1)}</span>
</div>
</div>
</div>
<ol className={styles.timeline}>
{SECONDARY_STEPS.map((step, i) => (
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
<div className={styles.stepDotCol}>
<div className={styles.stepDot} />
{i < SECONDARY_STEPS.length - 1 && <div className={styles.stepLine} />}
</div>
<div className={styles.stepContent}>
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
</div>
</li>
))}
</ol>
</section>
{/* Primary track */}
<section className={styles.track}>
<div className={styles.trackHeader}>
<div className={styles.trackHeaderLeft}>
<span className={styles.trackKicker}>Reception entry</span>
<h2 className={styles.trackTitle}>Primary school admissions</h2>
<p className={styles.trackSub}>For children starting Reception (Year R) in September. Applications are submitted in the autumn of the year before entry.</p>
</div>
<div className={styles.trackDates}>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Deadline</span>
<span className={styles.trackDateVal}>{fmtDate(1, 15)}</span>
</div>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Offer Day</span>
<span className={styles.trackDateVal}>{fmtDate(4, 16)}</span>
</div>
</div>
</div>
<ol className={styles.timeline}>
{PRIMARY_STEPS.map((step, i) => (
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
<div className={styles.stepDotCol}>
<div className={styles.stepDot} />
{i < PRIMARY_STEPS.length - 1 && <div className={styles.stepLine} />}
</div>
<div className={styles.stepContent}>
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
</div>
</li>
))}
</ol>
</section>
{/* Tips */}
<section className={styles.tips}>
<h2 className={styles.tipsHeading}>Three things most parents get wrong</h2>
<div className={styles.tipsGrid}>
{TIPS.map((tip, i) => (
<div key={i} className={styles.tipCard}>
<div className={styles.tipNumber}>{String(i + 1).padStart(2, '0')}</div>
<h3 className={styles.tipHeading}>{tip.heading}</h3>
<p className={styles.tipBody}>{tip.body}</p>
</div>
))}
</div>
</section>
</div>
);
}
+1
View File
@@ -25,6 +25,7 @@ export function Footer() {
<li><a href="/" className={styles.link}>Search schools</a></li> <li><a href="/" className={styles.link}>Search schools</a></li>
<li><a href="/rankings" className={styles.link}>Rankings</a></li> <li><a href="/rankings" className={styles.link}>Rankings</a></li>
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li> <li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
</ul> </ul>
</div> </div>
+153
View File
@@ -1173,3 +1173,156 @@
.loadMoreButton { .loadMoreButton {
min-width: 160px; min-width: 160px;
} }
/* =========================================================
Admissions Countdown Strip
========================================================= */
.admissionsStrip {
padding: 1.5rem 0 2rem;
border-top: 1px solid var(--border-color, #e5dfd5);
}
.stripHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.stripLabel {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.stripCta {
font-size: 0.82rem;
color: var(--accent-teal, #2d7d7d);
font-weight: 600;
text-decoration: none;
}
.stripCta:hover {
text-decoration: underline;
}
.countdownRail {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.countdownChip {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1rem 1.1rem 0.9rem;
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
display: flex;
flex-direction: column;
gap: 0.2rem;
position: relative;
overflow: hidden;
}
.countdownChip::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 12px 12px 0 0;
}
.countdownChipDeadline::before {
background: var(--accent-coral, #e07256);
}
.countdownChipOffer::before {
background: var(--accent-teal, #2d7d7d);
}
.countdownChipUrgent {
border-color: rgba(224, 114, 86, 0.4);
background: rgba(224, 114, 86, 0.04);
}
.chipTrack {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.15rem;
}
.chipTrackDeadline {
color: var(--accent-coral, #e07256);
}
.chipTrackOffer {
color: var(--accent-teal, #2d7d7d);
}
.chipTrackDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.chipDays {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 2.6rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
.countdownChipDeadline .chipDays,
.countdownChipUrgent .chipDays {
color: var(--accent-coral-dark, #c45a3f);
}
.countdownChipOffer .chipDays {
color: var(--accent-teal, #2d7d7d);
}
.chipDaysUnit {
font-family: 'DM Sans', sans-serif;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted, #6d685f);
margin-left: 0.2rem;
vertical-align: bottom;
line-height: 2;
}
.chipMilestone {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
line-height: 1.25;
margin-top: 0.1rem;
}
.chipDate {
font-size: 0.75rem;
color: var(--text-muted, #6d685f);
margin-top: 0.05rem;
}
@media (max-width: 768px) {
.countdownRail {
grid-template-columns: repeat(2, 1fr);
}
}
+80
View File
@@ -24,6 +24,39 @@ interface HomeViewProps {
totalSchools?: number | null; totalSchools?: number | null;
} }
function daysUntil(month: number, day: number): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target <= today) target = new Date(y + 1, month - 1, day);
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
}
function formatCountdownDate(month: number, day: number): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target <= today) target = new Date(y + 1, month - 1, day);
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
}
interface CountdownChipData {
type: 'deadline' | 'offer';
track: string;
milestone: string;
month: number;
day: number;
}
const ADMISSIONS_CHIPS: CountdownChipData[] = [
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
];
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) { export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -43,6 +76,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
const prevSearchParamsRef = useRef(searchParams.toString()); const prevSearchParamsRef = useRef(searchParams.toString());
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle'); const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
const [geoError, setGeoError] = useState<string | null>(null); const [geoError, setGeoError] = useState<string | null>(null);
const [chipDays, setChipDays] = useState<(number | null)[]>(ADMISSIONS_CHIPS.map(() => null));
const hasSearch = searchParams.get('search') || searchParams.get('postcode'); const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode'); const isLocationSearch = !!searchParams.get('postcode');
@@ -100,6 +134,11 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Compute admissions countdown days client-side to avoid SSR mismatch
useEffect(() => {
setChipDays(ADMISSIONS_CHIPS.map(c => daysUntil(c.month, c.day)));
}, []);
const handleLoadMore = async () => { const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return; if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true); setIsLoadingMore(true);
@@ -240,6 +279,47 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
</div> </div>
)} )}
{/* Admissions countdown strip — only on landing page */}
{!isSearchActive && (
<section className={styles.admissionsStrip}>
<div className={styles.stripHeader}>
<span className={styles.stripLabel}>Key admissions deadlines</span>
<a href="/admissions" className={styles.stripCta}>Full admissions guide </a>
</div>
<div className={styles.countdownRail}>
{ADMISSIONS_CHIPS.map((chip, i) => {
const days = chipDays[i];
const isUrgent = days !== null && days <= 14;
const chipClass = [
styles.countdownChip,
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
isUrgent ? styles.countdownChipUrgent : '',
].filter(Boolean).join(' ');
const trackClass = [
styles.chipTrack,
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
].join(' ');
return (
<div key={chip.milestone} className={chipClass}>
<span className={trackClass}>
<span className={styles.chipTrackDot} aria-hidden="true" />
{chip.track}
</span>
<div>
<span className={styles.chipDays}>{days ?? '—'}</span>
{days !== null && <span className={styles.chipDaysUnit}>days</span>}
</div>
<div className={styles.chipMilestone}>{chip.milestone}</div>
<div className={styles.chipDate}>
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
</div>
</div>
);
})}
</div>
</section>
)}
{/* How it works — only on landing page */} {/* How it works — only on landing page */}
{!isSearchActive && ( {!isSearchActive && (
<section className={styles.howItWorks}> <section className={styles.howItWorks}>
+6
View File
@@ -59,6 +59,12 @@ export function Navigation() {
> >
Rankings Rankings
</Link> </Link>
<Link
href="/admissions"
className={isActive('/admissions') ? `${styles.navLink} ${styles.active}` : styles.navLink}
>
Admissions
</Link>
</nav> </nav>
</div> </div>
</header> </header>