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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 3–6 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function Footer() {
|
||||
<li><a href="/" className={styles.link}>Search schools</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="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1173,3 +1173,156 @@
|
||||
.loadMoreButton {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,39 @@ interface HomeViewProps {
|
||||
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) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -43,6 +76,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||
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 isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -100,6 +134,11 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
.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 () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
setIsLoadingMore(true);
|
||||
@@ -240,6 +279,47 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
</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 */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.howItWorks}>
|
||||
|
||||
@@ -59,6 +59,12 @@ export function Navigation() {
|
||||
>
|
||||
Rankings
|
||||
</Link>
|
||||
<Link
|
||||
href="/admissions"
|
||||
className={isActive('/admissions') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||
>
|
||||
Admissions
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user