2026-04-15 17:00:21 +01:00
'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 );
2026-04-16 12:28:31 +01:00
if ( target < today ) target = new Date ( y + 1 , month - 1 , day );
2026-04-15 17:00:21 +01:00
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' ;
2026-07-01 22:33:22 +01:00
/* How SchoolCompare helps at this stage of the journey */
tool ?: { label : string ; href : string };
2026-04-15 17:00:21 +01:00
}
const SECONDARY_STEPS : Step [] = [
{
title : 'Check entry criteria' ,
2026-07-21 12:08:50 +01:00
body : 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely. Two deadlines catch parents out: grammar and other selective schools need separate entrance-test registration months earlier (often by September), and faith schools often want a supplementary information form sent directly to the school alongside the council application.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Find schools & view their admissions history' , href : '/' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Build and compare your shortlist' , href : '/compare' },
2026-04-15 17:00:21 +01:00
},
{
date : '31 October' ,
title : 'Application deadline' ,
2026-07-21 12:08:50 +01:00
body : 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.' ,
2026-04-15 17:00:21 +01:00
highlight : 'deadline' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Compare performance to order your preferences' , href : '/rankings' },
2026-04-15 17:00:21 +01:00
},
{
date : '1 March' ,
title : 'National Offer Day' ,
2026-07-21 12:08:50 +01:00
body : 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.' ,
2026-04-15 17:00:21 +01:00
highlight : 'offer' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Look up your allocated school' , href : '/' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Weigh your offer against your other choices' , href : '/compare' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Gather performance evidence for your case' , href : '/rankings' },
2026-04-15 17:00:21 +01:00
},
];
const PRIMARY_STEPS : Step [] = [
{
title : 'Research entry criteria' ,
2026-07-21 12:08:50 +01:00
body : 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy — and note that faith schools often require a supplementary information form sent directly to the school, in addition to the council application.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Find schools & view their admissions history' , href : '/' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Build and compare your shortlist' , href : '/compare' },
2026-04-15 17:00:21 +01:00
},
{
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' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Compare performance to order your preferences' , href : '/rankings' },
2026-04-15 17:00:21 +01:00
},
{
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' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Look up your allocated school' , href : '/' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Weigh your offer against your other choices' , href : '/compare' },
2026-04-15 17:00:21 +01:00
},
{
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.' ,
2026-07-01 22:33:22 +01:00
tool : { label : 'Gather performance evidence for your case' , href : '/rankings' },
2026-04-15 17:00:21 +01:00
},
];
interface Tip {
heading : string ;
body : string ;
}
const TIPS : Tip [] = [
{
heading : 'Equal preference rule' ,
2026-07-21 12:08:50 +01:00
body : 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.' ,
2026-04-15 17:00:21 +01:00
},
{
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 ────────────────────────────────────────── */
2026-04-17 20:50:00 +01:00
const NAV_ITEMS = [
{ id : 'primary' , label : 'Primary' },
{ id : 'secondary' , label : 'Secondary' },
{ id : 'tips' , label : 'Tips' },
] as const ;
type NavId = typeof NAV_ITEMS [ number ][ 'id' ];
2026-04-15 17:00:21 +01:00
export function AdmissionsView() {
const [ chipDays , setChipDays ] = useState < ( number | null )[] > ( CHIPS . map (() => null ));
2026-04-17 20:50:00 +01:00
const [ activeId , setActiveId ] = useState < NavId >( 'primary' );
2026-04-15 17:00:21 +01:00
useEffect (() => {
setChipDays ( CHIPS . map ( c => daysUntil ( c . month , c . day )));
}, []);
2026-04-17 20:50:00 +01:00
useEffect (() => {
const sections = NAV_ITEMS
. map ( item => document . getElementById ( item . id ))
. filter (( el ) : el is HTMLElement => el !== null );
if ( sections . length === 0 ) return ;
const observer = new IntersectionObserver (
( entries ) => {
const visible = entries
. filter ( e => e . isIntersecting )
. sort (( a , b ) => b . intersectionRatio - a . intersectionRatio );
if ( visible [ 0 ]) setActiveId ( visible [ 0 ]. target . id as NavId );
},
{ rootMargin : '-30% 0px -55% 0px' , threshold : [ 0 , 0.25 , 0.5 , 0.75 , 1 ] },
);
sections . forEach ( s => observer . observe ( s ));
return () => observer . disconnect ();
}, []);
const handleNavClick = ( e : React.MouseEvent < HTMLAnchorElement >, id : NavId ) => {
e . preventDefault ();
const el = document . getElementById ( id );
if ( ! el ) return ;
const top = el . getBoundingClientRect (). top + window . scrollY - 16 ;
window . scrollTo ({ top , behavior : 'smooth' });
setActiveId ( id );
};
2026-04-15 17:00:21 +01:00
return (
2026-04-17 20:50:00 +01:00
< div className = { styles . shell }>
{ /* In-page nav — sticky left rail on desktop, sticky top pills on mobile */ }
< aside className = { styles . nav } aria-label = "On this page" >
< div className = { styles . navLabel }> On this page </ div >
< ul className = { styles . navList }>
{ NAV_ITEMS . map ( item => {
const isActive = activeId === item . id ;
return (
< li key = { item . id }>
< a
href = { `# ${ item . id } ` }
onClick = {( e ) => handleNavClick ( e , item . id )}
className = {[ styles . navLink , isActive ? styles . navLinkActive : '' ]. filter ( Boolean ). join ( ' ' )}
aria-current = { isActive ? 'true' : undefined }
>
< span className = { styles . navDot } aria-hidden = "true" />
< span >{ item . label }</ span >
</ a >
</ li >
);
})}
</ ul >
</ aside >
< div className = { styles . page }>
2026-04-15 17:00:21 +01:00
{ /* 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 >
2026-04-16 12:02:14 +01:00
< span className = { styles . chipDays }>{ days === 0 ? 'Today' : ( days ?? '—' )}</ span >
{ days !== null && days > 0 && < span className = { styles . chipDaysUnit }> days </ span >}
2026-04-15 17:00:21 +01:00
</ div >
< div className = { styles . chipMilestone }>{ chip . milestone }</ div >
< div className = { styles . chipDate }>{ days !== null ? fmtDate ( chip . month , chip . day ) : '' }</ div >
</ div >
);
})}
</ div >
</ section >
{ /* Primary track */ }
2026-04-17 20:50:00 +01:00
< section id = "primary" className = { styles . track } style = {{ scrollMarginTop : '1rem' }}>
2026-04-15 17:00:21 +01:00
< 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 >
2026-07-01 22:33:22 +01:00
{ step . tool && (
< a
href = { step . tool . href }
className = { styles . stepTool }
data-umami-event = "admissions_tool_clicked"
data-umami-event-target = { step . tool . href }
>
{ step . tool . label }
< span className = { styles . stepToolArrow } aria-hidden = "true" > → </ span >
</ a >
)}
2026-04-15 17:00:21 +01:00
</ div >
</ li >
))}
</ ol >
</ section >
2026-04-17 20:50:00 +01:00
{ /* Secondary track */ }
< section id = "secondary" className = { styles . track } style = {{ scrollMarginTop : '1rem' }}>
< 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 >
2026-07-01 22:33:22 +01:00
{ step . tool && (
< a
href = { step . tool . href }
className = { styles . stepTool }
data-umami-event = "admissions_tool_clicked"
data-umami-event-target = { step . tool . href }
>
{ step . tool . label }
< span className = { styles . stepToolArrow } aria-hidden = "true" > → </ span >
</ a >
)}
2026-04-17 20:50:00 +01:00
</ div >
</ li >
))}
</ ol >
</ section >
2026-04-15 17:00:21 +01:00
{ /* Tips */ }
2026-04-17 20:50:00 +01:00
< section id = "tips" className = { styles . tips } style = {{ scrollMarginTop : '1rem' }}>
2026-04-15 17:00:21 +01:00
< 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 >
2026-04-17 20:50:00 +01:00
</ div >
2026-04-15 17:00:21 +01:00
</ div >
);
}