diff --git a/.env.e2e-live.example b/.env.e2e-live.example
index 4448895..1b91fd0 100644
--- a/.env.e2e-live.example
+++ b/.env.e2e-live.example
@@ -3,3 +3,10 @@
CV_E2E_BACKEND_ORIGIN=http://127.0.0.1:8080
CV_E2E_ADMIN_EMAIL=admin@dcs.ruh.ac.lk
CV_E2E_ADMIN_PASSWORD=replace-with-your-local-admin-password
+
+# A finalized shortlist containing at least one candidate with a saved CV.
+CV_E2E_FINALIZED_SHORTLIST_ID=replace-with-local-finalized-shortlist-uuid
+
+# An Internship Request backed by deterministic GPA/skill fixtures for Candidate Filtering.
+CV_E2E_FILTER_REQUEST_ID=replace-with-local-internship-request-uuid
+CV_E2E_FILTER_SEARCH=replace-with-known-candidate-name-or-index
diff --git a/.gitattributes b/.gitattributes
index 355261d..44f80bd 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,3 @@
+* text=auto eol=lf
docs/**/CV_Management_API_OpenAPI_v1.4.0.yaml text eol=lf whitespace=-trailing-space
+
diff --git a/Gateway_Intro_Rebuild_Implementation.md b/Gateway_Intro_Rebuild_Implementation.md
deleted file mode 100644
index df707c2..0000000
--- a/Gateway_Intro_Rebuild_Implementation.md
+++ /dev/null
@@ -1,1450 +0,0 @@
-# Gateway Intro and Landing Page Rebuild
-
-> **Implementation status:** Completed. This document retains the original rebuild proposal and
-> timing rationale. The delivered logo treatment was refined after the proposal: both the intro and
-> final gateway now use `LogoDrawReveal`, which draws one continuous rounded white SVG path with a
-> `220`-unit stroke. It has no scale expansion, highlight sweep, secondary guide stroke, or break
-> between the C and V. The source files listed below are authoritative when an embedded proposal
-> snippet differs from the final implementation.
-
-## Scope
-
-This implementation is based on the supplied `Frontend.zip` snapshot.
-
-The current gateway is a fixed, two-column page:
-
-- The left hero uses the existing dark gateway palette and a photographic background.
-- The left-side copy is static in `src/features/home/pages/HomePage.tsx`, original lines 11–16.
-- The right side contains the existing Student and Admin access cards.
-- The route is lazy-loaded and currently uses `GatewaySkeleton`, so the fallback also needs to match the first intro frame to prevent a skeleton flash before the animation starts.
-- Existing design tokens already define the institutional navy/slate palette, Google Sans typography, radii, shadows, and motion curves. This solution consumes those variables rather than introducing a second design system.
-
-## Resulting page flow
-
-| Phase | Timing | Behaviour |
-| ------------------ | ---------: | ----------------------------------------------------------------------------------------------------------- |
-| Logo reveal | 2,000 ms | A single continuous white SVG path draws the complete C/V mark without scaling or a secondary guide stroke. |
-| Caption 1 | 1,250 ms | “Build your academic profile.” |
-| Caption 2 | 1,250 ms | Smooth vertical scroll to “Showcase skills and experience.” |
-| Caption 3 | 1,250 ms | Smooth vertical scroll to “Connect with the right opportunities.” |
-| Landing transition | 420 ms | The intro overlay fades and scales away, revealing the final gateway. |
-| Final gateway | Continuous | The same three captions loop through a natural typewriter/delete sequence on the left side. |
-
-The intro’s slide viewport and final gateway page both hide scrollbars. The slide movement is transform-based, so it does not alter the document scroll position.
-
-## Design and integration decisions
-
-- All new selectors use the `gateway-v2-` namespace, preventing collisions with the existing gateway rules in `src/index.css`.
-- Existing global tokens and shared `.button` variants are reused.
-- The existing `/logo (2).png` asset remains the preload fallback; the animated runtime mark uses an inline SVG path, so no binary asset changes are required.
-- The CSS is loaded from `src/main.tsx`, so the Suspense fallback and lazy-loaded page share the same first-paint styling.
-- The gateway content is marked inert and hidden from assistive technology until the intro completes.
-- The Skip button traps keyboard focus during the overlay and Escape also skips.
-- After completion, focus moves to the gateway heading.
-- `prefers-reduced-motion` bypasses the long sequence and avoids the typewriter character animation.
-- No new npm dependencies are introduced.
-
-## File map
-
-### Existing files changed
-
-1. `src/main.tsx`
-2. `src/shared/skeletons/GatewaySkeleton.tsx`
-3. `src/features/home/pages/HomePage.tsx`
-
-### New files
-
-1. `src/features/home/data/gatewayCaptions.ts`
-2. `src/features/home/hooks/usePrefersReducedMotion.ts`
-3. `src/features/home/components/TypewriterText.tsx`
-4. `src/features/home/components/GatewayIntro.tsx`
-5. `src/features/home/components/LogoDrawReveal.tsx`
-6. `src/features/home/styles/gateway.css`
-7. `src/features/home/tests/GatewayIntro.test.tsx`
-8. `src/features/home/tests/HomePage.test.tsx`
-
----
-
-# Existing file modifications
-
-Line numbers below refer to the unmodified files in the supplied `Frontend.zip`.
-
-## `src/main.tsx`
-
-**Change location:** Insert the CSS import immediately after original line 5 (`import './index.css'`). No other original lines change.
-
-**Final file contents:**
-
-```tsx
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import App from './App'
-import { env } from './app/config/env'
-import './index.css'
-import './features/home/styles/gateway.css'
-import './styles/skeleton-system.css'
-import './styles/sprint78-wireframe-alignment.css'
-import './styles/internship-management-complete.css'
-import './styles/shortlisted-page.css'
-
-const rootElement = document.getElementById('root')
-
-if (!rootElement) {
- throw new Error('Application root element was not found.')
-}
-
-async function enableDevelopmentMocks() {
- if (!env.enableApiMocks || env.isProduction) return
- const { worker } = await import('./mocks/browser')
- await worker.start({ onUnhandledRequest: 'bypass' })
-}
-
-void enableDevelopmentMocks().then(() => {
- createRoot(rootElement).render(
-
-
- ,
- )
-})
-```
-
-## `src/shared/skeletons/GatewaySkeleton.tsx`
-
-**Change location:** Replace original lines 1–38 in full.
-
-**Final file contents:**
-
-```tsx
-import { SkeletonStatusRegion } from './SkeletonPrimitives'
-
-export function GatewaySkeleton() {
- return (
-
-
-
-
-
-
- )
-}
-```
-
-## `src/features/home/pages/HomePage.tsx`
-
-**Change location:** Replace original lines 1–63 in full.
-
-**Final file contents:**
-
-```tsx
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { routePaths } from '../../../app/config/routePaths'
-import { GatewayIntro } from '../components/GatewayIntro'
-import { TypewriterText } from '../components/TypewriterText'
-import { gatewayCaptions, gatewayCaptionTexts } from '../data/gatewayCaptions'
-
-export function HomePage() {
- const gatewayPageRef = useRef(null)
- const gatewayTitleRef = useRef(null)
- const [introComplete, setIntroComplete] = useState(false)
-
- const handleIntroComplete = useCallback(() => {
- setIntroComplete(true)
- }, [])
-
- useEffect(() => {
- if (!gatewayPageRef.current) return
-
- gatewayPageRef.current.inert = !introComplete
-
- if (introComplete) {
- gatewayTitleRef.current?.focus({ preventScroll: true })
- }
- }, [introComplete])
-
- return (
- <>
- {!introComplete ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
CV Management System
-
- Department Access Gateway
-
-
- {introComplete ? (
-
- ) : (
-
- {gatewayCaptionTexts[0]}
-
- )}
-
-
-
-
-
-
Secure role-based access
-
Select your role
-
Choose the workspace assigned to your university account.
-
-
-
-
-
- school
-
-
-
Student
-
Register or sign in with your university account.
-
-
-
- Login
-
-
- Register
-
-
-
-
-
-
- admin_panel_settings
-
-
-
Admin
-
Use your predefined administrator credentials to continue.
-
-
-
- Login
-
-
-
-
-
-
- >
- )
-}
-```
-
----
-
-# New files
-
-Create each file at the exact path shown.
-
-## `src/features/home/data/gatewayCaptions.ts`
-
-```ts
-export const gatewayCaptions = [
- {
- id: 'profile',
- text: 'Build your academic profile.',
- },
- {
- id: 'skills',
- text: 'Showcase skills and experience.',
- },
- {
- id: 'opportunities',
- text: 'Connect with the right opportunities.',
- },
-] as const
-
-export const gatewayCaptionTexts = gatewayCaptions.map((caption) => caption.text)
-
-export type GatewayCaption = (typeof gatewayCaptions)[number]
-```
-
-## `src/features/home/hooks/usePrefersReducedMotion.ts`
-
-```ts
-import { useEffect, useState } from 'react'
-
-const reducedMotionQuery = '(prefers-reduced-motion: reduce)'
-
-function getInitialPreference() {
- return typeof window !== 'undefined' && window.matchMedia?.(reducedMotionQuery).matches === true
-}
-
-export function usePrefersReducedMotion() {
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(getInitialPreference)
-
- useEffect(() => {
- const mediaQuery = window.matchMedia?.(reducedMotionQuery)
-
- if (!mediaQuery) return
-
- const handleChange = (event: MediaQueryListEvent) => {
- setPrefersReducedMotion(event.matches)
- }
-
- mediaQuery.addEventListener?.('change', handleChange)
-
- return () => {
- mediaQuery.removeEventListener?.('change', handleChange)
- }
- }, [])
-
- return prefersReducedMotion
-}
-```
-
-## `src/features/home/components/TypewriterText.tsx`
-
-```tsx
-import { useEffect, useState } from 'react'
-import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
-
-type TypewriterPhase = 'typing' | 'deleting'
-
-type TypewriterTextProps = {
- captions: readonly string[]
- className?: string
-}
-
-const fullCaptionHoldMs = 1500
-const betweenCaptionPauseMs = 260
-const reducedMotionCaptionHoldMs = 2800
-const deletingDelayMs = 26
-
-function getTypingDelay(character: string) {
- if (/[.!?]/.test(character)) return 180
- if (/[,;:]/.test(character)) return 110
- if (character === ' ') return 34
- return 58
-}
-
-export function TypewriterText({ captions, className = '' }: TypewriterTextProps) {
- const prefersReducedMotion = usePrefersReducedMotion()
- const [captionIndex, setCaptionIndex] = useState(0)
- const [characterCount, setCharacterCount] = useState(0)
- const [phase, setPhase] = useState('typing')
-
- const safeCaptionIndex = captions.length === 0 ? 0 : captionIndex % captions.length
- const currentCaption = captions[safeCaptionIndex] ?? ''
- const displayedCaption = prefersReducedMotion
- ? currentCaption
- : currentCaption.slice(0, characterCount)
-
- useEffect(() => {
- if (!prefersReducedMotion || captions.length < 2) return
-
- const timer = window.setTimeout(() => {
- setCaptionIndex((currentIndex) => (currentIndex + 1) % captions.length)
- }, reducedMotionCaptionHoldMs)
-
- return () => window.clearTimeout(timer)
- }, [captionIndex, captions.length, prefersReducedMotion])
-
- useEffect(() => {
- if (prefersReducedMotion || captions.length === 0) return
-
- if (phase === 'typing') {
- if (characterCount < currentCaption.length) {
- const nextCharacter = currentCaption.charAt(characterCount)
- const timer = window.setTimeout(() => {
- setCharacterCount((currentCount) => currentCount + 1)
- }, getTypingDelay(nextCharacter))
-
- return () => window.clearTimeout(timer)
- }
-
- const timer = window.setTimeout(() => {
- setPhase('deleting')
- }, fullCaptionHoldMs)
-
- return () => window.clearTimeout(timer)
- }
-
- if (phase === 'deleting') {
- if (characterCount > 0) {
- const timer = window.setTimeout(() => {
- setCharacterCount((currentCount) => Math.max(0, currentCount - 1))
- }, deletingDelayMs)
-
- return () => window.clearTimeout(timer)
- }
-
- const timer = window.setTimeout(() => {
- setCaptionIndex((currentIndex) => (currentIndex + 1) % captions.length)
- setPhase('typing')
- }, betweenCaptionPauseMs)
-
- return () => window.clearTimeout(timer)
- }
-
- return undefined
- }, [captions.length, characterCount, currentCaption, phase, prefersReducedMotion])
-
- return (
-
-
- {displayedCaption}
- |
-
-
- {currentCaption}
-
-
- )
-}
-```
-
-## `src/features/home/components/GatewayIntro.tsx`
-
-```tsx
-import { useCallback, useEffect, useRef, useState } from 'react'
-import type { GatewayCaption } from '../data/gatewayCaptions'
-import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
-
-type IntroPhase = 'logo' | 'slides' | 'exit'
-
-type GatewayIntroProps = {
- captions: readonly GatewayCaption[]
- onComplete: () => void
-}
-
-export const gatewayIntroTiming = {
- logoRevealMs: 2000,
- slideHoldMs: 1250,
- exitMs: 420,
- reducedMotionMs: 180,
-} as const
-
-export function GatewayIntro({ captions, onComplete }: GatewayIntroProps) {
- const prefersReducedMotion = usePrefersReducedMotion()
- const skipButtonRef = useRef(null)
- const hasCompletedRef = useRef(false)
- const [phase, setPhase] = useState('logo')
- const [activeSlide, setActiveSlide] = useState(0)
-
- const completeIntro = useCallback(() => {
- if (hasCompletedRef.current) return
-
- hasCompletedRef.current = true
- onComplete()
- }, [onComplete])
-
- const requestExit = useCallback(() => {
- if (prefersReducedMotion) {
- completeIntro()
- return
- }
-
- setPhase('exit')
- }, [completeIntro, prefersReducedMotion])
-
- useEffect(() => {
- skipButtonRef.current?.focus({ preventScroll: true })
- }, [])
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- event.preventDefault()
- requestExit()
- }
-
- if (event.key === 'Tab') {
- event.preventDefault()
- skipButtonRef.current?.focus({ preventScroll: true })
- }
- }
-
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [requestExit])
-
- useEffect(() => {
- if (prefersReducedMotion) {
- const timer = window.setTimeout(completeIntro, gatewayIntroTiming.reducedMotionMs)
- return () => window.clearTimeout(timer)
- }
-
- if (phase === 'logo') {
- const timer = window.setTimeout(() => {
- if (captions.length === 0) {
- setPhase('exit')
- return
- }
-
- setPhase('slides')
- }, gatewayIntroTiming.logoRevealMs)
-
- return () => window.clearTimeout(timer)
- }
-
- if (phase === 'slides') {
- const timer = window.setTimeout(() => {
- if (activeSlide < captions.length - 1) {
- setActiveSlide((currentSlide) => currentSlide + 1)
- return
- }
-
- setPhase('exit')
- }, gatewayIntroTiming.slideHoldMs)
-
- return () => window.clearTimeout(timer)
- }
-
- const timer = window.setTimeout(completeIntro, gatewayIntroTiming.exitMs)
- return () => window.clearTimeout(timer)
- }, [activeSlide, captions.length, completeIntro, phase, prefersReducedMotion])
-
- return (
-
-
- Skip intro
-
-
-
-
-
-
-
-
-
-
-
-
-
CV Management System
-
- {captions.map((caption, index) => {
- const offset = (index - activeSlide) * 112
- const isActive = phase === 'slides' && index === activeSlide
-
- return (
-
- {caption.text}
-
- )
- })}
-
-
-
- {captions.map((caption, index) => (
-
- ))}
-
-
-
- )
-}
-```
-
-## `src/features/home/styles/gateway.css`
-
-```css
-/* Suspense fallback: keep the first paint visually aligned with the intro. */
-.gateway-v2-preload {
- position: fixed;
- inset: 0;
- z-index: 1000;
- display: grid;
- place-items: center;
- min-height: 100dvh;
- overflow: hidden;
- background:
- radial-gradient(circle at 18% 18%, rgb(29 78 216 / 24%), transparent 34%),
- radial-gradient(circle at 82% 78%, rgb(30 64 175 / 20%), transparent 38%), var(--gateway-bg);
-}
-
-.gateway-v2-preload-decoration {
- position: absolute;
- inset: 0;
- opacity: 0.2;
- background-image:
- linear-gradient(rgb(148 163 184 / 12%) 1px, transparent 1px),
- linear-gradient(90deg, rgb(148 163 184 / 12%) 1px, transparent 1px);
- background-size: 56px 56px;
- mask-image: linear-gradient(to bottom, transparent, black 18%, black 75%, transparent);
-}
-
-.gateway-v2-preload-logo-stage {
- position: relative;
- z-index: 1;
- width: min(560px, 72vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
-}
-
-.gateway-v2-preload-logo {
- display: block;
- width: 100%;
- height: auto;
- filter: invert(1);
- mix-blend-mode: screen;
-}
-
-.gateway-v2-visually-hidden {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
-.gateway-v2-page,
-.gateway-v2-intro {
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.gateway-v2-page::-webkit-scrollbar,
-.gateway-v2-intro::-webkit-scrollbar {
- display: none;
-}
-
-/* Intro sequence */
-.gateway-v2-intro {
- position: fixed;
- inset: 0;
- z-index: 1000;
- display: grid;
- place-items: center;
- min-height: 100dvh;
- overflow: hidden;
- padding: clamp(24px, 5vw, 72px);
- background:
- radial-gradient(circle at 18% 18%, rgb(29 78 216 / 24%), transparent 34%),
- radial-gradient(circle at 82% 78%, rgb(30 64 175 / 20%), transparent 38%), var(--gateway-bg);
- color: var(--sidebar-text);
-}
-
-.gateway-v2-intro::after {
- content: '';
- position: absolute;
- inset: 0;
- pointer-events: none;
- background: linear-gradient(180deg, transparent 68%, rgb(2 6 23 / 46%) 100%);
-}
-
-.gateway-v2-intro-decoration {
- position: absolute;
- inset: 0;
- opacity: 0.2;
- pointer-events: none;
- background-image:
- linear-gradient(rgb(148 163 184 / 12%) 1px, transparent 1px),
- linear-gradient(90deg, rgb(148 163 184 / 12%) 1px, transparent 1px);
- background-size: 56px 56px;
- mask-image: linear-gradient(to bottom, transparent, black 18%, black 75%, transparent);
-}
-
-.gateway-v2-intro-skip {
- position: absolute;
- top: 18px;
- right: 18px;
- z-index: 4;
- min-height: 42px;
- color: var(--sidebar-text);
- background: rgb(15 23 42 / 76%);
- border-color: var(--sidebar-border);
- backdrop-filter: blur(12px);
-}
-
-.gateway-v2-intro-logo-stage {
- position: relative;
- z-index: 2;
- width: min(560px, 72vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
- transition:
- opacity 420ms var(--motion-standard),
- transform 520ms var(--motion-standard),
- filter 520ms var(--motion-standard);
-}
-
-.gateway-v2-intro-logo-window {
- width: 100%;
- height: 100%;
- display: grid;
- place-items: center;
- overflow: hidden;
- clip-path: inset(0 100% 0 0 round var(--radius-lg));
- animation: gatewayV2LogoWindow 2000ms var(--motion-standard) both;
-}
-
-.gateway-v2-intro-logo {
- display: block;
- width: 100%;
- height: auto;
- user-select: none;
- pointer-events: none;
- filter: invert(1);
- mix-blend-mode: screen;
- transform: scale(0.86);
- opacity: 0;
- animation: gatewayV2LogoImage 2000ms var(--motion-emphasized) both;
-}
-
-.gateway-v2-intro-logo-line {
- position: absolute;
- top: 18%;
- bottom: 18%;
- left: 0;
- width: 2px;
- opacity: 0;
- background: linear-gradient(to bottom, transparent, var(--sidebar-accent), transparent);
- box-shadow: 0 0 28px var(--sidebar-accent);
- animation: gatewayV2LogoSweep 2000ms var(--motion-standard) both;
-}
-
-.gateway-v2-intro--slides .gateway-v2-intro-logo-stage {
- opacity: 0;
- filter: blur(8px);
- transform: translateY(-18vh) scale(0.72);
- pointer-events: none;
-}
-
-.gateway-v2-intro-slides {
- position: absolute;
- z-index: 3;
- width: min(920px, calc(100% - 48px));
- display: grid;
- justify-items: center;
- gap: 18px;
- opacity: 0;
- transform: translateY(48px);
- pointer-events: none;
- transition:
- opacity 420ms var(--motion-standard),
- transform 520ms var(--motion-standard);
-}
-
-.gateway-v2-intro--slides .gateway-v2-intro-slides {
- opacity: 1;
- transform: translateY(0);
-}
-
-.gateway-v2-intro-kicker {
- margin: 0;
- color: var(--sidebar-accent);
- font-size: var(--font-size-sm);
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: 0.12em;
-}
-
-.gateway-v2-intro-slide-viewport {
- position: relative;
- width: 100%;
- height: clamp(120px, 20vw, 220px);
- overflow: hidden;
-}
-
-.gateway-v2-intro-slide {
- position: absolute;
- inset: 0;
- display: grid;
- place-items: center;
- margin: 0;
- padding: 0 24px;
- color: var(--sidebar-text);
- font-size: clamp(2rem, 6vw, 5.4rem);
- font-weight: 700;
- line-height: 1.02;
- letter-spacing: -0.035em;
- text-align: center;
- transition:
- transform 540ms var(--motion-standard),
- opacity 300ms var(--motion-standard);
- will-change: transform, opacity;
-}
-
-.gateway-v2-intro-progress {
- display: flex;
- align-items: center;
- gap: 9px;
-}
-
-.gateway-v2-intro-progress span {
- width: 9px;
- height: 9px;
- border-radius: var(--radius-pill);
- background: var(--sidebar-border);
- transition:
- width var(--motion-duration-medium) var(--motion-standard),
- background-color var(--motion-duration-medium) var(--motion-standard);
-}
-
-.gateway-v2-intro-progress span.is-active {
- width: 34px;
- background: var(--sidebar-accent);
-}
-
-.gateway-v2-intro--exit {
- opacity: 0;
- transform: scale(1.015);
- pointer-events: none;
- transition:
- opacity 420ms var(--motion-standard),
- transform 420ms var(--motion-standard);
-}
-
-/* Final gateway landing */
-.gateway-v2-page {
- position: fixed;
- inset: 0;
- z-index: 50;
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
- min-height: 100dvh;
- overflow-y: auto;
- background: var(--gateway-bg);
- color: var(--sidebar-text);
-}
-
-.gateway-v2-hero {
- position: relative;
- min-height: 100dvh;
- display: flex;
- align-items: center;
- overflow: hidden;
- padding: clamp(56px, 7vw, 112px);
-}
-
-.gateway-v2-hero-bg {
- position: absolute;
- inset: 0;
- background:
- linear-gradient(145deg, rgb(2 6 23 / 36%) 0%, rgb(2 6 23 / 88%) 82%),
- url('https://images.unsplash.com/photo-1550745165-9bc0b252726f?q=80&w=3174&auto=format&fit=crop')
- center / cover no-repeat;
-}
-
-.gateway-v2-hero::before,
-.gateway-v2-hero::after {
- content: '';
- position: absolute;
- z-index: 1;
- border-radius: var(--radius-pill);
- pointer-events: none;
- background: color-mix(in srgb, var(--sidebar-active) 28%, transparent);
- filter: blur(2px);
-}
-
-.gateway-v2-hero::before {
- width: 240px;
- height: 240px;
- top: 88px;
- right: -72px;
-}
-
-.gateway-v2-hero::after {
- width: 180px;
- height: 180px;
- bottom: 96px;
- left: -64px;
-}
-
-.gateway-v2-hero-content {
- position: relative;
- z-index: 2;
- width: min(760px, 100%);
-}
-
-.gateway-v2-brand-lockup {
- width: min(196px, 46vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
- margin-bottom: 24px;
- overflow: hidden;
-}
-
-.gateway-v2-logo {
- display: block;
- width: 100%;
- height: auto;
- filter: invert(1);
- mix-blend-mode: screen;
-}
-
-.gateway-v2-eyebrow,
-.gateway-v2-access-kicker {
- margin: 0 0 12px;
- color: var(--sidebar-accent);
- font-size: var(--font-size-sm);
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: 0.1em;
-}
-
-.gateway-v2-hero h1 {
- max-width: 840px;
- margin: 0;
- color: var(--sidebar-text);
- font-size: clamp(2.6rem, 7vw, 5.9rem);
- font-weight: 700;
- line-height: 0.96;
- letter-spacing: -0.045em;
-}
-
-.gateway-v2-typewriter,
-.gateway-v2-typewriter-placeholder {
- min-height: 2.2em;
- max-width: 680px;
- margin: 28px 0 0;
- color: var(--sidebar-muted);
- font-size: clamp(1.08rem, 1.7vw, 1.34rem);
- font-weight: 500;
- line-height: 1.55;
-}
-
-.gateway-v2-typewriter-copy {
- margin: 0;
-}
-
-.gateway-v2-typewriter-cursor {
- display: inline-block;
- margin-left: 3px;
- color: var(--sidebar-accent);
- font-weight: 400;
- animation: gatewayV2CursorBlink 820ms steps(1, end) infinite;
-}
-
-.gateway-v2-access {
- position: relative;
- min-height: 100dvh;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: clamp(24px, 4vw, 40px);
- padding: clamp(48px, 6vw, 88px);
- background: linear-gradient(180deg, var(--gateway-surface) 0%, var(--gateway-bg) 100%);
-}
-
-.gateway-v2-access-header {
- display: grid;
- justify-items: start;
- gap: 14px;
-}
-
-.gateway-v2-access-header h2 {
- margin: 0;
- color: var(--sidebar-text);
- font-size: clamp(2rem, 4vw, 3.2rem);
- font-weight: 900;
- line-height: 1.05;
- text-transform: uppercase;
-}
-
-.gateway-v2-access-header > p:last-child {
- max-width: 560px;
- margin: 0;
- color: var(--sidebar-muted);
- font-size: 1.1rem;
- line-height: 1.6;
-}
-
-.gateway-v2-split-panel {
- display: grid;
- grid-template-columns: 1fr;
- gap: 1px;
- overflow: hidden;
- border: 1px solid var(--sidebar-border);
- border-radius: var(--radius-xl);
- background: var(--sidebar-border);
- box-shadow: 0 32px 64px rgb(2 6 23 / 62%);
-}
-
-.gateway-v2-card {
- min-height: 210px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: flex-start;
- gap: 14px;
- padding: clamp(24px, 3vw, 34px);
- background: var(--gateway-surface);
- text-align: left;
- transition:
- transform var(--motion-duration-medium) var(--motion-standard),
- background-color var(--motion-duration-medium) var(--motion-standard);
-}
-
-.gateway-v2-card + .gateway-v2-card {
- border-top: 1px solid var(--sidebar-border);
-}
-
-.gateway-v2-card:hover {
- background: var(--gateway-surface-hover);
- transform: translateY(-8px);
-}
-
-.gateway-v2-card .material-symbols-outlined {
- width: 56px;
- height: 56px;
- display: grid;
- place-items: center;
- margin-bottom: 2px;
- border-radius: 50%;
- background: var(--sidebar-surface);
- color: var(--sidebar-accent);
- font-size: 31px;
-}
-
-.gateway-v2-card h3 {
- margin: 0;
- color: var(--sidebar-text);
- font-size: clamp(1.55rem, 2.6vw, 2.1rem);
- font-weight: 800;
- line-height: 1.05;
- text-transform: uppercase;
-}
-
-.gateway-v2-card p {
- max-width: 380px;
- margin: 0;
- color: var(--sidebar-muted);
- font-size: 1rem;
- line-height: 1.65;
-}
-
-.gateway-v2-actions {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-start;
- gap: 16px;
- margin-top: 4px;
-}
-
-@keyframes gatewayV2LogoWindow {
- 0% {
- clip-path: inset(0 100% 0 0 round var(--radius-lg));
- }
-
- 54%,
- 100% {
- clip-path: inset(0 0 0 0 round var(--radius-lg));
- }
-}
-
-@keyframes gatewayV2LogoImage {
- 0% {
- opacity: 0;
- filter: invert(1) blur(12px);
- transform: scale(0.86);
- }
-
- 48% {
- opacity: 1;
- filter: invert(1) blur(0);
- transform: scale(1.035);
- }
-
- 72%,
- 100% {
- opacity: 1;
- filter: invert(1) blur(0);
- transform: scale(1);
- }
-}
-
-@keyframes gatewayV2LogoSweep {
- 0% {
- left: 0;
- opacity: 0;
- }
-
- 12% {
- opacity: 1;
- }
-
- 56% {
- left: 100%;
- opacity: 1;
- }
-
- 62%,
- 100% {
- left: 100%;
- opacity: 0;
- }
-}
-
-@keyframes gatewayV2CursorBlink {
- 0%,
- 48% {
- opacity: 1;
- }
-
- 49%,
- 100% {
- opacity: 0;
- }
-}
-
-@media (max-width: 900px) {
- .gateway-v2-page {
- grid-template-columns: 1fr;
- }
-
- .gateway-v2-hero {
- min-height: 52dvh;
- padding: 88px 24px 48px;
- }
-
- .gateway-v2-hero h1 {
- font-size: clamp(2.35rem, 12vw, 3.6rem);
- }
-
- .gateway-v2-access {
- min-height: auto;
- gap: 24px;
- padding: 40px 24px 64px;
- }
-
- .gateway-v2-card {
- min-height: auto;
- gap: 16px;
- padding: 24px;
- }
-
- .gateway-v2-card .material-symbols-outlined {
- width: 48px;
- height: 48px;
- margin-bottom: 0;
- font-size: 28px;
- }
-
- .gateway-v2-actions,
- .gateway-v2-actions a {
- width: 100%;
- }
-}
-
-@media (max-width: 560px) {
- .gateway-v2-intro {
- padding: 20px;
- }
-
- .gateway-v2-intro-skip {
- top: 14px;
- right: 14px;
- }
-
- .gateway-v2-intro-logo-stage {
- width: min(88vw, 420px);
- }
-
- .gateway-v2-intro-slides {
- width: calc(100% - 32px);
- }
-
- .gateway-v2-intro-slide {
- padding: 0 8px;
- font-size: clamp(1.8rem, 10vw, 3.2rem);
- }
-
- .gateway-v2-brand-lockup {
- margin-bottom: 18px;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .gateway-v2-intro-logo-window {
- clip-path: none;
- }
-
- .gateway-v2-intro-logo {
- opacity: 1;
- filter: invert(1);
- transform: none;
- }
-
- .gateway-v2-intro-logo-line,
- .gateway-v2-typewriter-cursor {
- display: none;
- }
-}
-```
-
-## `src/features/home/tests/GatewayIntro.test.tsx`
-
-```tsx
-import { act, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { GatewayIntro, gatewayIntroTiming } from '../components/GatewayIntro'
-import { gatewayCaptions } from '../data/gatewayCaptions'
-
-describe('GatewayIntro', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- })
-
- afterEach(() => {
- vi.clearAllTimers()
- vi.useRealTimers()
- })
-
- it('runs the logo, slide, and exit phases in sequence', () => {
- const onComplete = vi.fn()
- const { container } = render(
- ,
- )
- const intro = container.querySelector('.gateway-v2-intro')
-
- expect(intro).toHaveAttribute('data-phase', 'logo')
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.logoRevealMs)
- })
-
- expect(intro).toHaveAttribute('data-phase', 'slides')
- expect(intro).toHaveAttribute('data-active-slide', '0')
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
- })
-
- expect(intro).toHaveAttribute('data-active-slide', '1')
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
- })
-
- expect(intro).toHaveAttribute('data-active-slide', '2')
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
- })
-
- expect(intro).toHaveAttribute('data-phase', 'exit')
- expect(onComplete).not.toHaveBeenCalled()
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
- })
-
- expect(onComplete).toHaveBeenCalledOnce()
- })
-
- it('allows the sequence to be skipped', () => {
- const onComplete = vi.fn()
- const { container } = render(
- ,
- )
-
- fireEvent.click(screen.getByRole('button', { name: 'Skip intro' }))
-
- expect(container.querySelector('.gateway-v2-intro')).toHaveAttribute('data-phase', 'exit')
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
- })
-
- expect(onComplete).toHaveBeenCalledOnce()
- })
-})
-```
-
-## `src/features/home/tests/HomePage.test.tsx`
-
-```tsx
-import { act, fireEvent, render, screen } from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { gatewayIntroTiming } from '../components/GatewayIntro'
-import { HomePage } from '../pages/HomePage'
-
-describe('HomePage', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- })
-
- afterEach(() => {
- vi.clearAllTimers()
- vi.useRealTimers()
- })
-
- it('reveals the gateway after the intro finishes', () => {
- const { container } = render(
-
-
- ,
- )
-
- const gateway = container.querySelector('.gateway-v2-page')
-
- expect(screen.getByRole('region', { name: 'Gateway introduction' })).toBeInTheDocument()
- expect(gateway).toHaveAttribute('aria-hidden', 'true')
-
- fireEvent.click(screen.getByRole('button', { name: 'Skip intro' }))
-
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
- })
-
- expect(screen.queryByRole('region', { name: 'Gateway introduction' })).not.toBeInTheDocument()
- expect(gateway).toHaveAttribute('aria-hidden', 'false')
- expect(screen.getByRole('link', { name: 'Register' })).toBeInTheDocument()
- expect(screen.getAllByRole('link', { name: 'Login' })).toHaveLength(2)
- })
-})
-```
-
----
-
-# Implementation notes
-
-## 1. Caption source of truth
-
-`gatewayCaptions.ts` is the only place where intro and typewriter captions are defined. Both experiences consume this data, so text cannot drift between the two sequences.
-
-To change a caption later, edit only the `text` value in that file. Keep each caption short enough to fit on one or two mobile lines.
-
-## 2. Intro timing
-
-Timing constants are exported from `GatewayIntro.tsx`:
-
-```ts
-export const gatewayIntroTiming = {
- logoRevealMs: 2000,
- slideHoldMs: 1250,
- exitMs: 420,
- reducedMotionMs: 180,
-} as const
-```
-
-The CSS logo animations also use `2000ms`; keep that duration synchronized with `logoRevealMs`.
-
-## 3. Hidden-scroll behaviour
-
-No native scrolling is used for the captions. Each caption is absolutely positioned inside an overflow-hidden viewport and translated vertically. The following rules also suppress any page scrollbar:
-
-```css
-.gateway-v2-page,
-.gateway-v2-intro {
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.gateway-v2-page::-webkit-scrollbar,
-.gateway-v2-intro::-webkit-scrollbar {
- display: none;
-}
-```
-
-The final gateway remains vertically scrollable on small screens even though the scrollbar itself is hidden.
-
-## 4. Existing gateway CSS
-
-The old `.gateway-*` rules in `src/index.css` are intentionally left untouched. The new page uses the `gateway-v2-*` namespace, so the old selectors cannot override the rebuilt page. Keeping them avoids a large unrelated edit to the global stylesheet and minimizes merge risk.
-
-They can be removed in a separate cleanup after confirming no older gateway markup is retained in another branch.
-
-## 5. Logo treatment
-
-The supplied logo is a black mark on an opaque white background. The following treatment allows it to sit on the existing dark gateway background without adding a new image asset:
-
-```css
-filter: invert(1);
-mix-blend-mode: screen;
-```
-
-A transparent SVG remains the preferred future asset because it would permit true path drawing and remove blend-mode dependence.
-
-## 6. Accessibility
-
-- The intro has a named region and a visible Skip control.
-- Keyboard focus is constrained to Skip while the overlay is active.
-- Escape triggers the same exit path.
-- The landing page uses `inert` plus `aria-hidden` until the overlay is removed.
-- The visual typewriter string is `aria-hidden`; assistive technology receives only complete captions through a polite live region.
-- Reduced-motion users see a brief static brand frame and then the landing page.
-- The final heading receives focus when the intro completes.
-
-## 7. Responsive behaviour
-
-At widths below 900 px:
-
-- The gateway changes from two columns to one.
-- The hero becomes a shorter top section.
-- Access cards and actions become full-width.
-- The final page remains scrollable with its scrollbar hidden.
-
-At widths below 560 px:
-
-- Intro typography and logo dimensions reduce.
-- The skip control maintains a safe edge offset.
-- Caption slides remain centered without horizontal overflow.
-
----
-
-# Validation
-
-Run the following from the repository root after applying the files:
-
-```bash
-npm ci
-npm run typecheck
-npm run lint
-npm test -- src/features/home/tests/GatewayIntro.test.tsx src/features/home/tests/HomePage.test.tsx
-npm run build
-```
-
-## Acceptance checks
-
-1. A cold visit to `/` shows the branded preload rather than the old gateway skeleton.
-2. The animated logo reveal lasts exactly two seconds.
-3. All three captions advance automatically with a smooth vertical movement.
-4. No scrollbar appears during the intro or while the mobile gateway page scrolls.
-5. The final landing appears automatically without a click.
-6. The left hero types, pauses, deletes, and loops through the same three captions.
-7. Student Login, Student Register, and Admin Login retain their existing route destinations.
-8. The theme toggle remains covered during the intro and becomes available on the landing.
-9. Escape and Skip both complete the intro.
-10. Reduced-motion mode bypasses the long animation.
-11. No console warnings occur under React Strict Mode.
-12. The new tests pass.
-
-## Validation performed while preparing this document
-
-- The new stylesheet passed a PostCSS syntax parse.
-- All new and modified TypeScript/TSX files passed TypeScript `transpileModule` syntax validation.
-- A full dependency install and application build could not be completed in the preparation environment because the configured npm registry returned HTTP 503 while fetching `zod`. The implementation therefore still needs the repository commands above to confirm full project-level type resolution, linting, tests, and production bundling in your normal development environment.
diff --git a/docs/testing/BMD_007_LIVE_ACCEPTANCE.md b/docs/testing/BMD_007_LIVE_ACCEPTANCE.md
new file mode 100644
index 0000000..3957156
--- /dev/null
+++ b/docs/testing/BMD_007_LIVE_ACCEPTANCE.md
@@ -0,0 +1,36 @@
+# BMD-007 Live Frontend Acceptance
+
+This suite exercises the real Spring Boot, PostgreSQL, XeLaTeX, and frontend integration. It does not intercept or mock API requests.
+
+## Prerequisites
+
+- backend running at `http://127.0.0.1:8080` with XeLaTeX available;
+- a dedicated, registered Student acceptance account;
+- an Admin acceptance account;
+- the Student account has a complete profile suitable for CV generation.
+
+Set credentials in the current PowerShell process. Do not commit them:
+
+```powershell
+$env:CV_LIVE_STUDENT_EMAIL="student@example.test"
+$env:CV_LIVE_STUDENT_PASSWORD="replace-locally"
+$env:CV_LIVE_ADMIN_EMAIL="admin@example.test"
+$env:CV_LIVE_ADMIN_PASSWORD="replace-locally"
+```
+
+If the backend uses another origin, set `CV_LIVE_BACKEND_URL`. The Vite proxy currently expects the application backend on port `8080`.
+
+Run:
+
+```powershell
+npm ci
+npm run e2e:cv-live
+```
+
+The three serial tests verify:
+
+1. Student preview, save, and real PDF download;
+2. Profile mutation, `OUTDATED`, replacement, restoration, and `CURRENT`;
+3. Admin latest-saved-CV visibility and real PDF download.
+
+The suite restores the Student's original profile summary and saves a final replacement so the active CV finishes in the `CURRENT` state.
diff --git a/e2e-live/candidate-filtering.live.spec.ts b/e2e-live/candidate-filtering.live.spec.ts
new file mode 100644
index 0000000..1f63296
--- /dev/null
+++ b/e2e-live/candidate-filtering.live.spec.ts
@@ -0,0 +1,122 @@
+import { expect, test, type Page, type Response } from '@playwright/test'
+
+const tokenStorageKey = 'cv-management.foundation-token'
+
+function requiredEnvironment(
+ name:
+ | 'CV_E2E_ADMIN_EMAIL'
+ | 'CV_E2E_ADMIN_PASSWORD'
+ | 'CV_E2E_FILTER_REQUEST_ID'
+ | 'CV_E2E_FILTER_SEARCH',
+) {
+ const value = process.env[name]?.trim()
+ if (!value) {
+ throw new Error(
+ `${name} is required for live Candidate Filtering acceptance. ` +
+ 'Copy .env.e2e-live.example to .env.e2e-live.local and provide local fixture data.',
+ )
+ }
+ return value
+}
+
+const adminEmail = requiredEnvironment('CV_E2E_ADMIN_EMAIL')
+const adminPassword = requiredEnvironment('CV_E2E_ADMIN_PASSWORD')
+const requestId = requiredEnvironment('CV_E2E_FILTER_REQUEST_ID')
+const candidateSearch = requiredEnvironment('CV_E2E_FILTER_SEARCH')
+
+async function login(page: Page) {
+ await page.goto('/admin/login', { waitUntil: 'domcontentloaded' })
+ await page.getByLabel('Admin Email Address').fill(adminEmail)
+ await page.getByLabel('Security Password').fill(adminPassword)
+ await page.getByRole('button', { name: 'Log In' }).click()
+ await expect(page).toHaveURL(/\/admin\/dashboard$/)
+ await expect
+ .poll(() => page.evaluate((key) => sessionStorage.getItem(key), tokenStorageKey))
+ .not.toBeNull()
+}
+
+function isCandidatePage(response: Response) {
+ const url = new URL(response.url())
+ return (
+ response.request().method() === 'GET' &&
+ /\/api\/v1\/admin\/candidate-filtering\/runs\/[0-9a-f-]+\/candidates$/.test(url.pathname)
+ )
+}
+
+async function openFilteringWorkspace(page: Page) {
+ const created = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname === '/api/v1/admin/candidate-filtering/runs',
+ )
+ const candidates = page.waitForResponse(isCandidatePage)
+ await page.goto(`/admin/candidate-filtering?requestId=${encodeURIComponent(requestId)}`, {
+ waitUntil: 'domcontentloaded',
+ })
+ expect((await created).status()).toBe(201)
+ const candidateResponse = await candidates
+ expect(candidateResponse.status()).toBe(200)
+ await expect(page).toHaveURL(/runId=[0-9a-f-]{36}/)
+ await expect(page.getByRole('heading', { level: 2, name: 'Matching Students' })).toBeVisible()
+ return candidateResponse
+}
+
+test.describe.serial('BMD-010 real-backend Candidate Filtering', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page)
+ })
+
+ test('creates a run and renders authoritative candidate enrichment', async ({ page }) => {
+ const response = await openFilteringWorkspace(page)
+ const body = (await response.json()) as {
+ items: Array<{
+ hasLatestSavedCv: boolean
+ hasExistingActiveShortlist: boolean
+ existingActiveShortlistCount: number
+ }>
+ }
+
+ expect(body.items.length).toBeGreaterThan(0)
+ for (const candidate of body.items) {
+ expect(typeof candidate.hasLatestSavedCv).toBe('boolean')
+ expect(typeof candidate.hasExistingActiveShortlist).toBe('boolean')
+ expect(candidate.existingActiveShortlistCount).toBeGreaterThanOrEqual(0)
+ expect(candidate.hasExistingActiveShortlist).toBe(candidate.existingActiveShortlistCount > 0)
+ }
+ await expect(page.locator('tbody tr').first()).toBeVisible()
+ await expect(page.getByText(/rank|score|match percentage/i)).toHaveCount(0)
+ })
+
+ test('changing GPA creates a new immutable filtering run', async ({ page }) => {
+ await openFilteringWorkspace(page)
+ const originalRunId = new URL(page.url()).searchParams.get('runId')
+ const created = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname === '/api/v1/admin/candidate-filtering/runs',
+ )
+ await page.getByLabel('Min Bound').fill('3.00')
+ expect((await created).status()).toBe(201)
+ await expect.poll(() => new URL(page.url()).searchParams.get('runId')).not.toBe(originalRunId)
+ expect(new URL(page.url()).searchParams.get('minGpa')).toBe('3')
+ })
+
+ test('search and sort are sent to the server', async ({ page }) => {
+ await openFilteringWorkspace(page)
+ const searched = page.waitForResponse(
+ (response) => isCandidatePage(response) && new URL(response.url()).searchParams.has('search'),
+ )
+ await page.getByLabel('Search candidates by name or index number').fill(candidateSearch)
+ expect((await searched).status()).toBe(200)
+ expect(new URL(page.url()).searchParams.get('candidateSearch')).toBe(candidateSearch)
+
+ const sorted = page.waitForResponse(
+ (response) =>
+ isCandidatePage(response) &&
+ new URL(response.url()).searchParams.get('sort') === 'fullName,asc',
+ )
+ await page.getByLabel('Sort candidate results').selectOption('fullName,asc')
+ expect((await sorted).status()).toBe(200)
+ expect(new URL(page.url()).searchParams.get('candidateSort')).toBe('fullName,asc')
+ })
+})
diff --git a/e2e-live/cv-generation.live.spec.ts b/e2e-live/cv-generation.live.spec.ts
new file mode 100644
index 0000000..311c4f2
--- /dev/null
+++ b/e2e-live/cv-generation.live.spec.ts
@@ -0,0 +1,192 @@
+import {
+ expect,
+ request as playwrightRequest,
+ test,
+ type APIRequestContext,
+ type Page,
+} from '@playwright/test'
+
+const backendUrl = process.env.CV_LIVE_BACKEND_URL ?? 'http://127.0.0.1:8080'
+const studentEmail = process.env.CV_LIVE_STUDENT_EMAIL
+const studentPassword = process.env.CV_LIVE_STUDENT_PASSWORD
+const adminEmail = process.env.CV_LIVE_ADMIN_EMAIL
+const adminPassword = process.env.CV_LIVE_ADMIN_PASSWORD
+
+type LoginResponse = { accessToken: string }
+type StudentProfile = { studentId: string; summary: string | null; version: number }
+type SavedCv = { revision: number }
+
+let api: APIRequestContext
+let studentToken: string
+let adminToken: string
+let studentId: string
+let originalSummary: string | null
+let profileVersion: number
+
+function requireCredentials() {
+ const missing = [
+ ['CV_LIVE_STUDENT_EMAIL', studentEmail],
+ ['CV_LIVE_STUDENT_PASSWORD', studentPassword],
+ ['CV_LIVE_ADMIN_EMAIL', adminEmail],
+ ['CV_LIVE_ADMIN_PASSWORD', adminPassword],
+ ]
+ .filter(([, value]) => !value)
+ .map(([name]) => name)
+
+ if (missing.length) {
+ throw new Error(`Missing live CV acceptance variables: ${missing.join(', ')}`)
+ }
+}
+
+async function loginApi(role: 'student' | 'admin', email: string, password: string) {
+ const response = await api.post(`/api/v1/auth/${role}/login`, { data: { email, password } })
+ expect(
+ response.ok(),
+ `${role} API login failed: ${response.status()} ${await response.text()}`,
+ ).toBeTruthy()
+ return ((await response.json()) as LoginResponse).accessToken
+}
+
+async function loginUi(page: Page, role: 'student' | 'admin', email: string, password: string) {
+ await page.goto(`/${role}/login`, { waitUntil: 'domcontentloaded' })
+ await page.getByLabel(role === 'student' ? 'University Email' : 'Admin Email Address').fill(email)
+ await page.getByLabel(role === 'student' ? 'Password' : 'Security Password').fill(password)
+ await page.getByRole('button', { name: 'Log In' }).click()
+ await expect(page).toHaveURL(new RegExp(`/${role}/dashboard$`))
+}
+
+async function openCvBuilder(page: Page) {
+ await page.goto('/student/cv-builder', { waitUntil: 'domcontentloaded' })
+ await expect(page.getByRole('heading', { level: 1, name: 'LaTeX CV Builder' })).toBeVisible()
+ await expect(page.getByRole('button', { name: /Generate Preview|Update Preview/ })).toBeEnabled()
+}
+
+async function generateAndSave(page: Page) {
+ const previewResponse = page.waitForResponse(
+ (response) =>
+ response.url().endsWith('/api/v1/me/cv/preview') && response.request().method() === 'POST',
+ )
+ await page.getByRole('button', { name: /Generate Preview|Update Preview/ }).click()
+ expect((await previewResponse).status()).toBe(200)
+ await expect(page.getByTitle('Generated CV visual preview')).toBeVisible()
+
+ const saveResponse = page.waitForResponse(
+ (response) => response.url().endsWith('/api/v1/me/cv') && response.request().method() === 'PUT',
+ )
+ await page.getByRole('button', { name: 'Save Current CV Version' }).click()
+ const saved = await saveResponse
+ expect([200, 201]).toContain(saved.status())
+ await expect(page.getByText(/CV saved|CV updated/)).toBeVisible()
+ return (await saved.json()) as SavedCv
+}
+
+async function patchSummary(summary: string | null, version: number) {
+ const response = await api.patch('/api/v1/me/profile', {
+ data: { summary },
+ headers: {
+ Authorization: `Bearer ${studentToken}`,
+ 'If-Match': `"${version}"`,
+ },
+ })
+ expect(
+ response.ok(),
+ `Profile update failed: ${response.status()} ${await response.text()}`,
+ ).toBeTruthy()
+ return (await response.json()) as StudentProfile
+}
+
+test.describe.serial('BMD-007 live CV acceptance', () => {
+ test.beforeAll(async () => {
+ requireCredentials()
+ api = await playwrightRequest.newContext({ baseURL: backendUrl })
+ studentToken = await loginApi('student', studentEmail!, studentPassword!)
+ adminToken = await loginApi('admin', adminEmail!, adminPassword!)
+
+ const profileResponse = await api.get('/api/v1/me/profile', {
+ headers: { Authorization: `Bearer ${studentToken}` },
+ })
+ expect(profileResponse.ok()).toBeTruthy()
+ const profile = (await profileResponse.json()) as StudentProfile
+ studentId = profile.studentId
+ originalSummary = profile.summary
+ profileVersion = profile.version
+ })
+
+ test.afterAll(async () => {
+ await api?.dispose()
+ })
+
+ test('Student previews, saves, and downloads a real PDF', async ({ page }) => {
+ await loginUi(page, 'student', studentEmail!, studentPassword!)
+ await openCvBuilder(page)
+ await generateAndSave(page)
+
+ const downloadResponse = page.waitForResponse((response) =>
+ response.url().endsWith('/api/v1/me/cv/download'),
+ )
+ await page.getByRole('button', { name: 'Download Current CV PDF' }).click()
+ const response = await downloadResponse
+ expect(response.status()).toBe(200)
+ expect(response.headers()['content-type']).toContain('application/pdf')
+ expect((await response.body()).subarray(0, 5).toString()).toBe('%PDF-')
+ })
+
+ test('Profile mutation marks the CV OUTDATED and replacement restores CURRENT', async ({
+ page,
+ }) => {
+ const probe = `BMD-007 live freshness probe ${Date.now()}`
+ const mutated = await patchSummary(probe, profileVersion)
+ profileVersion = mutated.version
+
+ const outdatedResponse = await api.get('/api/v1/me/cv/source-freshness', {
+ headers: { Authorization: `Bearer ${studentToken}` },
+ })
+ expect(outdatedResponse.ok()).toBeTruthy()
+ expect(await outdatedResponse.json()).toMatchObject({ status: 'OUTDATED' })
+
+ await loginUi(page, 'student', studentEmail!, studentPassword!)
+ await openCvBuilder(page)
+ await expect(page.getByRole('heading', { name: 'Your saved CV needs an update' })).toBeVisible()
+ const replacement = await generateAndSave(page)
+ expect(replacement.revision).toBeGreaterThan(1)
+
+ const restored = await patchSummary(originalSummary, profileVersion)
+ profileVersion = restored.version
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ await expect(
+ page.getByRole('button', { name: /Generate Preview|Update Preview/ }),
+ ).toBeEnabled()
+ await generateAndSave(page)
+
+ const currentResponse = await api.get('/api/v1/me/cv/source-freshness', {
+ headers: { Authorization: `Bearer ${studentToken}` },
+ })
+ expect(currentResponse.ok()).toBeTruthy()
+ expect(await currentResponse.json()).toMatchObject({ status: 'CURRENT' })
+ })
+
+ test('Admin sees and downloads the Student latest saved CV', async ({ page }) => {
+ await loginUi(page, 'admin', adminEmail!, adminPassword!)
+ await page.goto(`/admin/students/${studentId}`, { waitUntil: 'domcontentloaded' })
+ await expect(page.getByText('Latest saved CV', { exact: true })).toBeVisible()
+ await expect(page.getByText('Current', { exact: true })).toBeVisible()
+
+ const metadata = await api.get(`/api/v1/admin/students/${studentId}/latest-cv`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ })
+ expect(metadata.ok()).toBeTruthy()
+ expect(await metadata.json()).toMatchObject({
+ availability: 'AVAILABLE',
+ freshnessStatus: 'CURRENT',
+ })
+
+ const downloadResponse = page.waitForResponse((response) =>
+ response.url().endsWith(`/api/v1/admin/students/${studentId}/latest-cv/download`),
+ )
+ await page.getByRole('button', { name: 'Download latest CV' }).click()
+ const response = await downloadResponse
+ expect(response.status()).toBe(200)
+ expect(response.headers()['content-type']).toContain('application/pdf')
+ expect((await response.body()).subarray(0, 5).toString()).toBe('%PDF-')
+ })
+})
diff --git a/e2e-live/shortlist-exports.live.spec.ts b/e2e-live/shortlist-exports.live.spec.ts
new file mode 100644
index 0000000..61897c6
--- /dev/null
+++ b/e2e-live/shortlist-exports.live.spec.ts
@@ -0,0 +1,100 @@
+import { expect, test, type Page } from '@playwright/test'
+
+const tokenStorageKey = 'cv-management.foundation-token'
+
+function requiredEnvironment(
+ name: 'CV_E2E_ADMIN_EMAIL' | 'CV_E2E_ADMIN_PASSWORD' | 'CV_E2E_FINALIZED_SHORTLIST_ID',
+) {
+ const value = process.env[name]?.trim()
+ if (!value) {
+ throw new Error(
+ `${name} is required for live Shortlist acceptance. ` +
+ 'Copy .env.e2e-live.example to .env.e2e-live.local and provide local fixture data.',
+ )
+ }
+ return value
+}
+
+const adminEmail = requiredEnvironment('CV_E2E_ADMIN_EMAIL')
+const adminPassword = requiredEnvironment('CV_E2E_ADMIN_PASSWORD')
+const shortlistId = requiredEnvironment('CV_E2E_FINALIZED_SHORTLIST_ID')
+
+async function login(page: Page) {
+ await page.goto('/admin/login', { waitUntil: 'domcontentloaded' })
+ await page.getByLabel('Admin Email Address').fill(adminEmail)
+ await page.getByLabel('Security Password').fill(adminPassword)
+ await page.getByRole('button', { name: 'Log In' }).click()
+ await expect(page).toHaveURL(/\/admin\/dashboard$/)
+ await expect
+ .poll(() => page.evaluate((key) => sessionStorage.getItem(key), tokenStorageKey))
+ .not.toBeNull()
+}
+
+async function openFixture(page: Page) {
+ await page.goto(`/admin/shortlists?shortlistId=${encodeURIComponent(shortlistId)}`, {
+ waitUntil: 'domcontentloaded',
+ })
+ await expect(
+ page.getByRole('heading', { level: 1, name: 'Shortlisted Candidates' }),
+ ).toBeVisible()
+ const dialog = page.getByRole('dialog').first()
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'Download Final Shortlist' })).toBeEnabled()
+ await expect(dialog.getByRole('button', { name: 'Download All CVs' })).toBeEnabled()
+ return dialog
+}
+
+async function acknowledgeCompilation(page: Page) {
+ const compiling = page.getByRole('alertdialog', { name: 'Compiling Pipeline' })
+ await expect(compiling).toBeVisible()
+ await compiling.getByRole('button', { name: 'Acknowledge' }).click()
+}
+
+test.describe.serial('BMD-011 real-backend shortlist exports', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page)
+ })
+
+ test('loads the configured finalized shortlist and its real candidate data', async ({ page }) => {
+ const dialog = await openFixture(page)
+ await expect(dialog.getByText(/GPA:/).first()).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'CV', exact: true }).first()).toBeVisible()
+ })
+
+ test('creates, polls, and downloads the shortlist CSV', async ({ page }) => {
+ const dialog = await openFixture(page)
+ const created = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname === `/api/v1/admin/exports/shortlists/${shortlistId}`,
+ )
+ const download = page.waitForEvent('download')
+
+ await dialog.getByRole('button', { name: 'Download Final Shortlist' }).click()
+ expect((await created).status()).toBe(202)
+ await acknowledgeCompilation(page)
+
+ const artifact = await download
+ expect(artifact.suggestedFilename()).toMatch(/\.csv$/)
+ expect((await artifact.createReadStream())?.readable).toBe(true)
+ })
+
+ test('creates, polls, and downloads the bulk latest-CV ZIP', async ({ page }) => {
+ const dialog = await openFixture(page)
+ const created = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname ===
+ `/api/v1/admin/exports/shortlists/${shortlistId}/bulk-cvs`,
+ )
+ const download = page.waitForEvent('download')
+
+ await dialog.getByRole('button', { name: 'Download All CVs' }).click()
+ expect((await created).status()).toBe(202)
+ await acknowledgeCompilation(page)
+
+ const artifact = await download
+ expect(artifact.suggestedFilename()).toMatch(/\.zip$/)
+ expect((await artifact.createReadStream())?.readable).toBe(true)
+ })
+})
diff --git a/e2e/candidate-filtering.spec.ts b/e2e/candidate-filtering.spec.ts
index 88b247d..c095a6c 100644
--- a/e2e/candidate-filtering.spec.ts
+++ b/e2e/candidate-filtering.spec.ts
@@ -180,7 +180,7 @@ test('Admin opens the protected Candidate Filtering workspace', async ({ page })
await expect(
page.getByText(
- 'Recruitment decision-support workspace. Select an active internship request, adjust deterministic runtime filters, review matching students, and manually finalize the shortlist.',
+ 'Recruitment decision-support workspace. Select an internship request, adjust deterministic runtime filters, review matching students, and manually finalize the shortlist.',
),
).toBeVisible()
@@ -194,7 +194,7 @@ test('Admin opens the protected Candidate Filtering workspace', async ({ page })
await expect(
page.getByText(
- 'Select an active internship request to load the latest committed student data. Adjusting runtime criteria refreshes the deterministic results automatically.',
+ 'Select an internship request to load the latest committed student data. Adjusting runtime criteria refreshes the deterministic results automatically.',
),
).toBeVisible()
diff --git a/e2e/motion-accessibility.spec.ts b/e2e/motion-accessibility.spec.ts
index 912291c..d6f1135 100644
--- a/e2e/motion-accessibility.spec.ts
+++ b/e2e/motion-accessibility.spec.ts
@@ -187,11 +187,12 @@ test('gateway and page entrances use centralized motion and fully stop for reduc
await page.emulateMedia({ reducedMotion: 'no-preference' })
await page.goto('/', { waitUntil: 'domcontentloaded' })
- const gatewayMotion = await readMotionStyle(
- page.locator('.logo-draw-reveal--once .logo-draw-reveal__mark-stroke'),
- )
- expect(gatewayMotion.animationName).toBe('gatewayV2DrawLogo')
- expect(gatewayMotion.animationDuration).toBe('2s')
+ await expect(page.getByRole('button', { name: /skip intro/i })).toBeVisible()
+ const skipButtonMotion = await readMotionStyle(page.locator('.gateway-v2-intro-cinema-skip'))
+ expect(transitionProperties(skipButtonMotion.transitionProperty)).toContain('transform')
+
+ await page.getByRole('button', { name: /skip intro/i }).click()
+ await expect(page.getByRole('heading', { level: 2, name: 'Select your role' })).toBeVisible()
const gatewayCardMotion = await readMotionStyle(page.locator('.gateway-v2-card').first())
expect(transitionProperties(gatewayCardMotion.transitionProperty)).toContain('transform')
@@ -207,12 +208,12 @@ test('gateway and page entrances use centralized motion and fully stop for reduc
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { level: 2, name: 'Select your role' })).toBeVisible()
- const reducedGateway = await readMotionStyle(
- page.locator('.logo-draw-reveal--loop .logo-draw-reveal__mark-stroke'),
- )
+ const reducedLogo = await readMotionStyle(page.locator('.gateway-v2-static-logo'))
const reducedCard = await readMotionStyle(page.locator('.gateway-v2-card').first())
- expect(reducedGateway.animationName).toBe('none')
+ expect(reducedLogo.transitionDuration.split(',').every((value) => value.trim() === '0s')).toBe(
+ true,
+ )
expect(reducedCard.transitionDuration.split(',').every((value) => value.trim() === '0s')).toBe(
true,
)
diff --git a/package-lock.json b/package-lock.json
index 41196e9..374b288 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -113,7 +113,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -473,7 +472,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -497,7 +495,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1949,7 +1946,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2034,7 +2032,6 @@
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2045,7 +2042,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2056,7 +2052,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2130,7 +2125,6 @@
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
@@ -2505,7 +2499,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2685,7 +2678,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
@@ -3068,7 +3060,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/dompurify": {
"version": "3.4.12",
@@ -3255,7 +3248,6 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4187,7 +4179,6 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -4340,6 +4331,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4773,7 +4765,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4874,6 +4865,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -4889,6 +4881,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -4911,7 +4904,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4921,7 +4913,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4934,7 +4925,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/react-refresh": {
"version": "0.17.0",
@@ -5556,7 +5548,6 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5653,7 +5644,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -5767,7 +5757,6 @@
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
diff --git a/package.json b/package.json
index 0b52b17..9a57fad 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,10 @@
"openapi:generate": "node scripts/generate-api-client.mjs",
"verify:scope": "node scripts/verify-removed-scope.mjs",
"verify:removed-scope": "node scripts/verify-removed-scope.mjs",
- "validate-env": "node scripts/validate-env.mjs"
+ "validate-env": "node scripts/validate-env.mjs",
+ "e2e:cv-live": "playwright test --config=playwright.cv-live.config.ts",
+ "e2e:shortlists-live": "playwright test --config=playwright.shortlist-live.config.ts",
+ "e2e:candidate-filtering-live": "playwright test --config=playwright.candidate-filtering-live.config.ts"
},
"dependencies": {
"@tanstack/react-query": "^5.62.16",
diff --git a/playwright.candidate-filtering-live.config.ts b/playwright.candidate-filtering-live.config.ts
new file mode 100644
index 0000000..85e75c4
--- /dev/null
+++ b/playwright.candidate-filtering-live.config.ts
@@ -0,0 +1,55 @@
+import { existsSync } from 'node:fs'
+import { defineConfig, devices } from '@playwright/test'
+
+const localEnvironmentFile = '.env.e2e-live.local'
+if (existsSync(localEnvironmentFile)) {
+ process.loadEnvFile(localEnvironmentFile)
+}
+
+const backendOrigin =
+ process.env.CV_E2E_BACKEND_ORIGIN?.trim().replace(/\/$/, '') || 'http://127.0.0.1:8080'
+const frontendHost = '127.0.0.1'
+const frontendPort = 5177
+const frontendOrigin = `http://${frontendHost}:${frontendPort}`
+const localChromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
+
+export default defineConfig({
+ testDir: './e2e-live',
+ testMatch: /candidate-filtering\.live\.spec\.ts/,
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ timeout: 120_000,
+ reporter: [
+ ['list'],
+ ['html', { open: 'never', outputFolder: 'playwright-report/candidate-filtering-live' }],
+ ],
+ expect: { timeout: 30_000 },
+ use: {
+ baseURL: frontendOrigin,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ webServer: {
+ command: `npm run dev -- --mode e2e --host ${frontendHost} --port ${frontendPort} --strictPort`,
+ url: frontendOrigin,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ VITE_API_BASE_URL: `${backendOrigin}/api/v1`,
+ VITE_ENABLE_API_MOCKS: 'false',
+ },
+ },
+ projects: [
+ {
+ name: 'chromium-candidate-filtering-live',
+ use: {
+ ...devices['Desktop Chrome'],
+ ...(localChromiumExecutable
+ ? { launchOptions: { executablePath: localChromiumExecutable } }
+ : {}),
+ },
+ },
+ ],
+})
diff --git a/playwright.cv-live.config.ts b/playwright.cv-live.config.ts
new file mode 100644
index 0000000..084c719
--- /dev/null
+++ b/playwright.cv-live.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const frontendHost = '127.0.0.1'
+const frontendPort = 5175
+
+export default defineConfig({
+ testDir: './e2e-live',
+ testMatch: /cv-generation\.live\.spec\.ts/,
+ fullyParallel: false,
+ workers: 1,
+ timeout: 180_000,
+ reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report/cv-live' }]],
+ expect: { timeout: 30_000 },
+ use: {
+ baseURL: `http://${frontendHost}:${frontendPort}`,
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+ },
+ webServer: {
+ command: `npm run dev -- --mode e2e --host ${frontendHost} --port ${frontendPort} --strictPort`,
+ url: `http://${frontendHost}:${frontendPort}`,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ projects: [
+ {
+ name: 'chromium-cv-live',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/playwright.shortlist-live.config.ts b/playwright.shortlist-live.config.ts
new file mode 100644
index 0000000..b2c7580
--- /dev/null
+++ b/playwright.shortlist-live.config.ts
@@ -0,0 +1,55 @@
+import { existsSync } from 'node:fs'
+import { defineConfig, devices } from '@playwright/test'
+
+const localEnvironmentFile = '.env.e2e-live.local'
+if (existsSync(localEnvironmentFile)) {
+ process.loadEnvFile(localEnvironmentFile)
+}
+
+const backendOrigin =
+ process.env.CV_E2E_BACKEND_ORIGIN?.trim().replace(/\/$/, '') || 'http://127.0.0.1:8080'
+const frontendHost = '127.0.0.1'
+const frontendPort = 5176
+const frontendOrigin = `http://${frontendHost}:${frontendPort}`
+const localChromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
+
+export default defineConfig({
+ testDir: './e2e-live',
+ testMatch: /shortlist-exports\.live\.spec\.ts/,
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ timeout: 120_000,
+ reporter: [
+ ['list'],
+ ['html', { open: 'never', outputFolder: 'playwright-report/shortlists-live' }],
+ ],
+ expect: { timeout: 30_000 },
+ use: {
+ baseURL: frontendOrigin,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ webServer: {
+ command: `npm run dev -- --mode e2e --host ${frontendHost} --port ${frontendPort} --strictPort`,
+ url: frontendOrigin,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ VITE_API_BASE_URL: `${backendOrigin}/api/v1`,
+ VITE_ENABLE_API_MOCKS: 'false',
+ },
+ },
+ projects: [
+ {
+ name: 'chromium-shortlists-live',
+ use: {
+ ...devices['Desktop Chrome'],
+ ...(localChromiumExecutable
+ ? { launchOptions: { executablePath: localChromiumExecutable } }
+ : {}),
+ },
+ },
+ ],
+})
diff --git a/public/assets/cv-logo.png b/public/assets/cv-logo.png
new file mode 100644
index 0000000..a02811a
Binary files /dev/null and b/public/assets/cv-logo.png differ
diff --git a/public/assets/gateway-logo.png b/public/assets/gateway-logo.png
new file mode 100644
index 0000000..9ca4eff
Binary files /dev/null and b/public/assets/gateway-logo.png differ
diff --git a/public/videos/Required Intro video.mp4 b/public/videos/Required Intro video.mp4
new file mode 100644
index 0000000..11a83cc
Binary files /dev/null and b/public/videos/Required Intro video.mp4 differ
diff --git a/src/app/config/routePaths.ts b/src/app/config/routePaths.ts
index d54fceb..dcacf22 100644
--- a/src/app/config/routePaths.ts
+++ b/src/app/config/routePaths.ts
@@ -24,6 +24,7 @@ export const routePaths = {
adminInternships: '/admin/internships',
adminCandidateFiltering: '/admin/candidate-filtering',
adminShortlists: '/admin/shortlists',
+ adminEligibleStudents: '/admin/eligible-students',
unauthorized: '/unauthorized',
} as const
diff --git a/src/app/layouts/AdminLayout.test.tsx b/src/app/layouts/AdminLayout.test.tsx
index 97b9907..a755882 100644
--- a/src/app/layouts/AdminLayout.test.tsx
+++ b/src/app/layouts/AdminLayout.test.tsx
@@ -121,6 +121,9 @@ describe('AdminLayout', () => {
expect(screen.getAllByRole('button', { name: /switch to dark mode/i })).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Log Out' }))
+ const logoutDialog = await screen.findByRole('dialog', { name: 'Log Out' })
+ expect(logout).not.toHaveBeenCalled()
+ await user.click(within(logoutDialog).getByRole('button', { name: 'Log Out' }))
expect(logout).toHaveBeenCalledOnce()
})
diff --git a/src/app/layouts/AdminLayout.tsx b/src/app/layouts/AdminLayout.tsx
index 27ba313..e24083c 100644
--- a/src/app/layouts/AdminLayout.tsx
+++ b/src/app/layouts/AdminLayout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'
+import { LogoutConfirmDialog } from '../../shared/components/overlays/LogoutConfirmDialog'
import { ThemeToggle } from '../../shared/components/ui/ThemeToggle'
import { useAuth } from '../../shared/hooks/useAuth'
import { AdminSidebar } from './admin/AdminSidebar'
@@ -20,6 +21,7 @@ export function AdminLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(isMobileViewportNow)
+ const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = useState(false)
const menuButtonRef = useRef(null)
const sidebarRef = useRef(null)
const firstNavigationItemRef = useRef(null)
@@ -105,7 +107,7 @@ export function AdminLayout() {
isMobileViewport={isMobileViewport}
navigationItems={adminNavigation}
onCloseMobile={closeMobileDrawer}
- onLogout={() => void auth.logout()}
+ onLogout={() => setIsLogoutConfirmOpen(true)}
onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)}
sidebarRef={sidebarRef}
/>
@@ -153,6 +155,16 @@ export function AdminLayout() {
+
+ {isLogoutConfirmOpen ? (
+ setIsLogoutConfirmOpen(false)}
+ onConfirm={async () => {
+ await auth.logout()
+ setIsLogoutConfirmOpen(false)
+ }}
+ />
+ ) : null}
)
}
diff --git a/src/app/layouts/RootLayout.tsx b/src/app/layouts/RootLayout.tsx
index 20f944e..31ed5ef 100644
--- a/src/app/layouts/RootLayout.tsx
+++ b/src/app/layouts/RootLayout.tsx
@@ -34,6 +34,7 @@ const adminWorkspaceRoutes = [
routePaths.adminInternships,
routePaths.adminCandidateFiltering,
routePaths.adminShortlists,
+ routePaths.adminEligibleStudents,
] as const
function isAdminWorkspacePath(pathname: string) {
@@ -89,7 +90,7 @@ export function RootLayout() {
} ${isAdminWorkspace ? 'app-main-admin-workspace' : ''}`.trim()
}
>
- {isWorkspace ? (
+ {isWorkspace || location.pathname === routePaths.home ? (
outlet
) : (
diff --git a/src/app/layouts/StudentLayout.test.tsx b/src/app/layouts/StudentLayout.test.tsx
index fe5ba5d..ecfd776 100644
--- a/src/app/layouts/StudentLayout.test.tsx
+++ b/src/app/layouts/StudentLayout.test.tsx
@@ -132,6 +132,9 @@ describe('StudentLayout', () => {
expect(drawerCloseButton).toHaveFocus()
await user.click(screen.getByRole('button', { name: 'Log Out' }))
+ const logoutDialog = await screen.findByRole('dialog', { name: 'Log Out' })
+ expect(logout).not.toHaveBeenCalled()
+ await user.click(within(logoutDialog).getByRole('button', { name: 'Log Out' }))
expect(logout).toHaveBeenCalledOnce()
await user.keyboard('{Escape}')
diff --git a/src/app/layouts/StudentLayout.tsx b/src/app/layouts/StudentLayout.tsx
index 234881c..57f21c1 100644
--- a/src/app/layouts/StudentLayout.tsx
+++ b/src/app/layouts/StudentLayout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'
+import { LogoutConfirmDialog } from '../../shared/components/overlays/LogoutConfirmDialog'
import { ThemeToggle } from '../../shared/components/ui/ThemeToggle'
import { useAuth } from '../../shared/hooks/useAuth'
import { StudentSidebar } from './student/StudentSidebar'
@@ -23,6 +24,7 @@ export function StudentLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(isMobileViewportNow)
+ const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = useState(false)
const menuButtonRef = useRef(null)
const sidebarRef = useRef(null)
const firstNavigationItemRef = useRef(null)
@@ -136,7 +138,7 @@ export function StudentLayout() {
isMobileViewport={isMobileViewport}
navigationItems={studentNavigation}
onCloseMobile={closeMobileDrawer}
- onLogout={() => void auth.logout()}
+ onLogout={() => setIsLogoutConfirmOpen(true)}
onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)}
sidebarRef={sidebarRef}
studentName={studentName}
@@ -185,6 +187,16 @@ export function StudentLayout() {
+
+ {isLogoutConfirmOpen ? (
+ setIsLogoutConfirmOpen(false)}
+ onConfirm={async () => {
+ await auth.logout()
+ setIsLogoutConfirmOpen(false)
+ }}
+ />
+ ) : null}
)
}
diff --git a/src/app/layouts/admin/adminNavigation.ts b/src/app/layouts/admin/adminNavigation.ts
index d122df7..b1b6659 100644
--- a/src/app/layouts/admin/adminNavigation.ts
+++ b/src/app/layouts/admin/adminNavigation.ts
@@ -17,4 +17,5 @@ export const adminNavigation: readonly AdminNavigationItem[] = [
icon: 'filter_alt',
},
{ label: 'Shortlists', route: routePaths.adminShortlists, icon: 'assignment_turned_in' },
+ { label: 'Eligible Students', route: routePaths.adminEligibleStudents, icon: 'how_to_reg' },
]
diff --git a/src/app/router/lazyRoutes.ts b/src/app/router/lazyRoutes.ts
index 14e79fe..155db2f 100644
--- a/src/app/router/lazyRoutes.ts
+++ b/src/app/router/lazyRoutes.ts
@@ -1,8 +1,5 @@
import { lazy } from 'react'
-export const HomePage = lazy(() =>
- import('../../features/home/pages/HomePage').then((module) => ({ default: module.HomePage })),
-)
export const StudentSignUpPage = lazy(() =>
import('../../features/student-auth/pages/StudentSignUpPage').then((module) => ({
default: module.StudentSignUpPage,
@@ -125,3 +122,9 @@ export const ShortlistsPage = lazy(() =>
default: module.ShortlistsPage,
})),
)
+
+export const EligibleStudentsPage = lazy(() =>
+ import('../../features/eligible-students/pages/EligibleStudentsPage').then((module) => ({
+ default: module.EligibleStudentsPage,
+ })),
+)
diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx
index 1e57af3..1dee19b 100644
--- a/src/app/router/routes.tsx
+++ b/src/app/router/routes.tsx
@@ -15,6 +15,7 @@ import {
RequireVerificationContextRoute,
} from './routeGuards'
import { fallbackRoutes } from './fallbackRoutes'
+import { HomePage } from '../../features/home/pages/HomePage'
import {
AdminCreatePasswordPage,
AdminDashboardPage,
@@ -24,12 +25,12 @@ import {
InternshipManagementPage,
CandidateFilteringPage,
ShortlistsPage,
+ EligibleStudentsPage,
AdminForgotPasswordPage,
AdminLoginPage,
AdminVerifyResetOtpPage,
CreatePasswordPage,
ForgotPasswordPage,
- HomePage,
StudentDashboardPage,
StudentProfilePage,
StudentSkillsPage,
@@ -51,7 +52,6 @@ import {
CandidateFilteringSkeleton,
CvBuilderSkeleton,
FormSkeleton,
- GatewaySkeleton,
RegisteredStudentsSkeleton,
StudentDashboardSkeleton,
StudentDeepDiveSkeleton,
@@ -72,7 +72,7 @@ export const routes: RouteObject[] = [
element: ,
errorElement: ,
children: [
- { index: true, element: withSuspense( , ) },
+ { index: true, element: },
{
element: ,
children: [
@@ -271,6 +271,10 @@ export const routes: RouteObject[] = [
path: routePaths.adminShortlists,
element: withSuspense( , ),
},
+ {
+ path: routePaths.adminEligibleStudents,
+ element: withSuspense( ),
+ },
],
},
...fallbackRoutes,
diff --git a/src/features/academic-ledger/api/academicLedgerApi.ts b/src/features/academic-ledger/api/academicLedgerApi.ts
index dabde3c..b355636 100644
--- a/src/features/academic-ledger/api/academicLedgerApi.ts
+++ b/src/features/academic-ledger/api/academicLedgerApi.ts
@@ -85,4 +85,10 @@ export const academicLedgerApi = {
)
return ledgerCommitResponseSchema.parse(response)
},
+
+ async remove(uploadId: string) {
+ await httpClient(`/admin/academic-ledger/uploads/${encodeURIComponent(uploadId)}`, {
+ method: 'DELETE',
+ })
+ },
}
diff --git a/src/features/academic-ledger/components/LedgerUploadPanel.tsx b/src/features/academic-ledger/components/LedgerUploadPanel.tsx
index c43aa3e..17d284f 100644
--- a/src/features/academic-ledger/components/LedgerUploadPanel.tsx
+++ b/src/features/academic-ledger/components/LedgerUploadPanel.tsx
@@ -43,7 +43,7 @@ export function LedgerUploadPanel({
const parsed = academicLedgerFileSchema.safeParse(nextFile)
if (!parsed.success) {
setFile(null)
- setValidationMessage(parsed.error.issues[0]?.message ?? 'Choose a valid CSV file.')
+ setValidationMessage(parsed.error.issues[0]?.message ?? 'Choose a valid CSV or Excel file.')
if (inputRef.current) inputRef.current.value = ''
return
}
@@ -68,20 +68,17 @@ export function LedgerUploadPanel({
return (
-
Upload academic records here.
-
- Select one official UTF-8 CSV ledger file. The file is parsed, staged, and validated
- before any academic record can be committed.
-
+
Upload academic records
+
Upload a CSV or Excel file. It's staged and validated before you commit it.
cloud_upload
- Drag & drop official transcript file here or click to browse
- Supported format: official academic CSV ledger file · Maximum size: 5 MiB
+ Drag & drop a file here, or click to browse
+ CSV or Excel (.xlsx) · Max 5 MiB
- Required headers and row values are validated by the backend before commit.
+ Columns and values are checked automatically before you can commit.
{file ? (
@@ -139,11 +136,11 @@ export function LedgerUploadPanel({
file && onUpload(file)}>
- Process and Stage Ledger
+ Upload
{file ? (
- Clear selected file
+ Clear
) : null}
diff --git a/src/features/academic-ledger/components/LedgerUploadsTable.tsx b/src/features/academic-ledger/components/LedgerUploadsTable.tsx
index e43c1d4..67b69f6 100644
--- a/src/features/academic-ledger/components/LedgerUploadsTable.tsx
+++ b/src/features/academic-ledger/components/LedgerUploadsTable.tsx
@@ -3,13 +3,17 @@ import { Button } from '../../../shared/components/ui/Button'
import { StatusBadge } from '../../../shared/components/ui/StatusBadge'
import { mapUploadStatus, mapValidationStatus } from '../mappers/academicLedgerMappers'
+const DELETE_BLOCKED_STATUSES = new Set(['PROCESSING', 'COMMITTING', 'COMMITTED'])
+
export function LedgerUploadsTable({
items,
+ onDelete,
onSelect,
selectedId,
}: {
items: ApiAcademicLedgerUploadSummaryResponse[]
selectedId: string | null
+ onDelete: (item: ApiAcademicLedgerUploadSummaryResponse) => void
onSelect: (uploadId: string) => void
}) {
return (
@@ -51,7 +55,7 @@ export function LedgerUploadsTable({
{validation.label}
{item.totalRows}
-
+
onSelect(item.uploadId)}
@@ -59,6 +63,11 @@ export function LedgerUploadsTable({
>
Inspect
+ {!DELETE_BLOCKED_STATUSES.has(item.uploadStatus) ? (
+ onDelete(item)} variant="secondary">
+ Remove
+
+ ) : null}
)
diff --git a/src/features/academic-ledger/hooks/useLedgerUpload.ts b/src/features/academic-ledger/hooks/useLedgerUpload.ts
index 2d2ea7e..d35f428 100644
--- a/src/features/academic-ledger/hooks/useLedgerUpload.ts
+++ b/src/features/academic-ledger/hooks/useLedgerUpload.ts
@@ -45,3 +45,13 @@ export function useUploadLedger() {
},
})
}
+
+export function useDeleteLedgerUpload() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (uploadId: string) => academicLedgerApi.remove(uploadId),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: academicLedgerKeys.uploads() })
+ },
+ })
+}
diff --git a/src/features/academic-ledger/pages/AcademicLedgerPage.tsx b/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
index ac2d79b..40abfa0 100644
--- a/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
+++ b/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
@@ -1,41 +1,38 @@
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { SearchInput } from '../../../shared/components/data/SearchInput'
import { PaginationBar } from '../../../shared/components/data/PaginationBar'
import { EmptyState } from '../../../shared/components/feedback/EmptyState'
import { ErrorState } from '../../../shared/components/feedback/ErrorState'
import { PageHeader } from '../../../shared/components/layout/PageHeader'
+import { ConfirmDialog } from '../../../shared/components/overlays/ConfirmDialog'
+import { Button } from '../../../shared/components/ui/Button'
import { LedgerSelectedBatchSkeleton, LedgerUploadsTableSkeleton } from '../../../shared/skeletons'
-import { LedgerAcademicInspection } from '../components/LedgerAcademicInspection'
import { LedgerCommitControl } from '../components/LedgerCommitControl'
import { LedgerReviewSection } from '../components/LedgerReviewSection'
import { LedgerUploadPanel } from '../components/LedgerUploadPanel'
import { LedgerUploadStatus } from '../components/LedgerUploadStatus'
import { LedgerUploadsTable } from '../components/LedgerUploadsTable'
import { useAcademicLedgerUrlState } from '../hooks/useAcademicLedgerUrlState'
-import { useLedgerUploadDetail, useLedgerUploads, useUploadLedger } from '../hooks/useLedgerUpload'
+import {
+ useDeleteLedgerUpload,
+ useLedgerUploadDetail,
+ useLedgerUploads,
+ useUploadLedger,
+} from '../hooks/useLedgerUpload'
+import type { ApiAcademicLedgerUploadSummaryResponse } from '../../../shared/api/generated/cvManagementApi.types'
const pageTitle = 'Academic Ledger Management | CV Management & Filtering System'
-const pageDescription =
- 'Centralized academic data validation repository. Import official undergraduate transcripts ' +
- 'via batch files to evaluate data parameters, review staged records, and protect academic data ' +
- 'from unauthorized modification.'
+const pageDescription = 'Upload official transcripts, review them, and commit academic records.'
export function AcademicLedgerPage() {
- const {
- state,
- rowSearchInput,
- studentSearchInput,
- selectUpload,
- setRowSearchInput,
- setStudentSearchInput,
- updateRows,
- updateStudents,
- updateUploads,
- } = useAcademicLedgerUrlState()
+ const { state, rowSearchInput, selectUpload, setRowSearchInput, updateRows, updateUploads } =
+ useAcademicLedgerUrlState()
const uploads = useLedgerUploads(state.uploads)
const selected = useLedgerUploadDetail(state.uploadId)
const upload = useUploadLedger()
+ const deleteUpload = useDeleteLedgerUpload()
+ const [deleting, setDeleting] = useState(null)
useEffect(() => {
const previousTitle = document.title
@@ -69,13 +66,6 @@ export function AcademicLedgerPage() {
}
/>
-
-
{state.uploadId && selected.isPending ? : null}
{selected.data ? : null}
{selected.isError ? (
@@ -147,6 +137,7 @@ export function AcademicLedgerPage() {
{uploads.data?.items.length ? (
@@ -170,6 +161,45 @@ export function AcademicLedgerPage() {
/>
) : null}
+
+ {deleting ? (
+ setDeleting(null)}
+ title="Remove upload"
+ >
+
+ Remove {deleting.originalFilename} ? This cannot be undone.
+
+ {deleteUpload.isError ? (
+
+ {mapApiError(deleteUpload.error, 'protected').message}
+
+ ) : null}
+
+ setDeleting(null)}
+ variant="secondary"
+ >
+ Cancel
+
+ {
+ deleteUpload.mutate(deleting.uploadId, {
+ onSuccess: () => {
+ if (state.uploadId === deleting.uploadId) selectUpload(null)
+ setDeleting(null)
+ },
+ })
+ }}
+ >
+ Remove
+
+
+
+ ) : null}
)
}
diff --git a/src/features/academic-ledger/schemas/ledgerSchemas.ts b/src/features/academic-ledger/schemas/ledgerSchemas.ts
index 7a69a7d..5a37a4c 100644
--- a/src/features/academic-ledger/schemas/ledgerSchemas.ts
+++ b/src/features/academic-ledger/schemas/ledgerSchemas.ts
@@ -47,11 +47,16 @@ export const ledgerValidationErrorSchema: z.ZodType =
- ledgerUploadSummaryObject
+// The bundled OpenAPI contract still declares `contentType` as the `text/csv` literal only; the
+// backend and this schema now also accept Excel (.xlsx) uploads, so the generated API type is
+// widened locally here rather than narrowing the runtime schema to match a stale contract.
+type WithLedgerContentType = Omit & { contentType: z.infer }
+
+export const ledgerUploadSummarySchema: z.ZodType<
+ WithLedgerContentType
+> = ledgerUploadSummaryObject
-export const ledgerUploadDetailSchema: z.ZodType =
- ledgerUploadSummaryObject
- .extend({
- statusMessage: z.string().min(1).max(500),
- nextPollAfterSeconds: z.number().int().min(1).max(30).nullable(),
- })
- .strict()
+export const ledgerUploadDetailSchema: z.ZodType<
+ WithLedgerContentType
+> = ledgerUploadSummaryObject
+ .extend({
+ statusMessage: z.string().min(1).max(500),
+ nextPollAfterSeconds: z.number().int().min(1).max(30).nullable(),
+ })
+ .strict()
-export const pagedLedgerUploadsSchema: z.ZodType =
- createPagedResponseSchema(ledgerUploadSummarySchema)
+export const pagedLedgerUploadsSchema: z.ZodType<
+ Omit & {
+ items: WithLedgerContentType[]
+ }
+> = createPagedResponseSchema(ledgerUploadSummarySchema)
export const ledgerStagedRowSchema: z.ZodType = z
.object({
@@ -140,8 +155,12 @@ export const ledgerCommitResponseSchema: z.ZodType {
it('rejects non-CSV files before upload and accepts a valid CSV', async () => {
const user = userEvent.setup({ applyAccept: false })
renderPage()
- const input = await screen.findByLabelText('Official academic ledger CSV')
+ const input = await screen.findByLabelText('Official academic ledger file')
await user.upload(input, new File(['not csv'], 'results.txt', { type: 'text/plain' }))
- expect(screen.getByRole('alert')).toHaveTextContent('Choose a .csv file')
- expect(screen.getByRole('button', { name: 'Process and Stage Ledger' })).toBeDisabled()
+ expect(screen.getByRole('alert')).toHaveTextContent('Choose a .csv or .xlsx file')
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled()
await user.upload(
input,
new File(['student,course\n1,CS4010'], 'results.csv', { type: 'text/csv' }),
)
- await user.click(screen.getByRole('button', { name: 'Process and Stage Ledger' }))
+ await user.click(screen.getByRole('button', { name: 'Upload' }))
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('uploadId='))
expect(
await screen.findByText('The file was accepted and processing has started.'),
@@ -98,26 +97,13 @@ describe('AcademicLedgerPage upload workflow', () => {
uploadId: String(params.uploadId),
})
}),
- http.get('/api/v1/admin/students', async () => {
- await delay(120)
- return HttpResponse.json({
- items: registeredStudentsFixture,
- page: {
- page: 0,
- size: 5,
- totalElements: registeredStudentsFixture.length,
- totalPages: 2,
- sort: 'fullName,asc',
- },
- })
- }),
)
const view = renderPage(`${routePaths.adminAcademicLedger}?uploadId=${uploadId}`)
expect(
view.getAllByRole('heading', { level: 1, name: 'Academic Ledger Management' }),
).toHaveLength(1)
- expect(view.getAllByLabelText('Official academic ledger CSV')).toHaveLength(1)
+ expect(view.getAllByLabelText('Official academic ledger file')).toHaveLength(1)
expect(
view.getByRole('status', { name: 'Loading selected ledger batch' }),
).toBeInTheDocument()
@@ -131,7 +117,7 @@ describe('AcademicLedgerPage upload workflow', () => {
expect(
view.getAllByRole('heading', { level: 1, name: 'Academic Ledger Management' }),
).toHaveLength(1)
- expect(view.getAllByLabelText('Official academic ledger CSV')).toHaveLength(1)
+ expect(view.getAllByLabelText('Official academic ledger file')).toHaveLength(1)
},
)
})
diff --git a/src/features/admin-auth/components/AdminCreatePasswordForm.tsx b/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
index 75a38b5..35ea987 100644
--- a/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
+++ b/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FormField } from '../../../shared/components/forms/FormField'
-import { TextInput } from '../../../shared/components/forms/TextInput'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { Button } from '../../../shared/components/ui/Button'
import {
adminCreatePasswordSchema,
@@ -40,13 +40,12 @@ export function AdminCreatePasswordForm({ isSubmitting, onSubmit }: AdminCreateP
Use at least 8 characters with uppercase, lowercase, number, and special character.
-
setValues((current) => ({ ...current, newPassword: event.target.value }))
}
- type="password"
value={values.newPassword}
/>
@@ -55,13 +54,12 @@ export function AdminCreatePasswordForm({ isSubmitting, onSubmit }: AdminCreateP
htmlFor="admin-confirm-password"
label="Confirm New Password"
>
-
setValues((current) => ({ ...current, confirmPassword: event.target.value }))
}
- type="password"
value={values.confirmPassword}
/>
diff --git a/src/features/admin-auth/components/AdminLoginForm.tsx b/src/features/admin-auth/components/AdminLoginForm.tsx
index 79e5a5e..7758d7c 100644
--- a/src/features/admin-auth/components/AdminLoginForm.tsx
+++ b/src/features/admin-auth/components/AdminLoginForm.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { FormField } from '../../../shared/components/forms/FormField'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { TextInput } from '../../../shared/components/forms/TextInput'
import { Button } from '../../../shared/components/ui/Button'
import {
@@ -46,14 +47,13 @@ export function AdminLoginForm({ isSubmitting, onSubmit }: AdminLoginFormProps)
/>
-
setValues((current) => ({ ...current, password: event.target.value }))
}
placeholder="Enter your security password"
- type="password"
value={values.password}
/>
diff --git a/src/features/admin-auth/pages/AdminLoginPage.tsx b/src/features/admin-auth/pages/AdminLoginPage.tsx
index 8ac0227..1a7db75 100644
--- a/src/features/admin-auth/pages/AdminLoginPage.tsx
+++ b/src/features/admin-auth/pages/AdminLoginPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { authStorage } from '../../../shared/auth/authStorage'
@@ -44,6 +44,12 @@ export function AdminLoginPage() {
}
>
+
+
+ arrow_back
+
+ Back
+
Admin Login
{message ? (
diff --git a/src/features/candidate-filtering/components/CandidateResultsTable.tsx b/src/features/candidate-filtering/components/CandidateResultsTable.tsx
index 666b9a8..fe81c64 100644
--- a/src/features/candidate-filtering/components/CandidateResultsTable.tsx
+++ b/src/features/candidate-filtering/components/CandidateResultsTable.tsx
@@ -145,10 +145,10 @@ export function CandidateResultsTable({
tone={candidate.hasExistingActiveShortlist ? 'neutral' : 'success'}
>
{candidate.hasExistingActiveShortlist
- ? `Already shortlisted in ${candidate.existingActiveShortlistCount} active request${
+ ? `Already shortlisted in ${candidate.existingActiveShortlistCount} other shortlist${
candidate.existingActiveShortlistCount === 1 ? '' : 's'
}`
- : 'No other active shortlists'}
+ : 'No other shortlists'}
diff --git a/src/features/candidate-filtering/components/CandidateResultsWorkspace.tsx b/src/features/candidate-filtering/components/CandidateResultsWorkspace.tsx
index 9e1a307..0a06755 100644
--- a/src/features/candidate-filtering/components/CandidateResultsWorkspace.tsx
+++ b/src/features/candidate-filtering/components/CandidateResultsWorkspace.tsx
@@ -135,7 +135,7 @@ export function CandidateResultsWorkspace({
{!state.runId ? (
diff --git a/src/features/candidate-filtering/components/CandidateSelectionPanel.tsx b/src/features/candidate-filtering/components/CandidateSelectionPanel.tsx
index 1aa5c79..b0099c0 100644
--- a/src/features/candidate-filtering/components/CandidateSelectionPanel.tsx
+++ b/src/features/candidate-filtering/components/CandidateSelectionPanel.tsx
@@ -129,7 +129,7 @@ export function CandidateSelectionPanel({
) : selectedRequest.data ? (
- Active placement target
+ Selected company
{selectedRequest.data.company.name}
diff --git a/src/features/candidate-filtering/components/SelectedCandidatesReviewModal.tsx b/src/features/candidate-filtering/components/SelectedCandidatesReviewModal.tsx
index a78d044..099ec60 100644
--- a/src/features/candidate-filtering/components/SelectedCandidatesReviewModal.tsx
+++ b/src/features/candidate-filtering/components/SelectedCandidatesReviewModal.tsx
@@ -136,7 +136,7 @@ export function SelectedCandidatesReviewModal({
title: 'Shortlist allocation finalized',
message: `${finalized.selectedCandidateCount} manually selected candidate${finalized.selectedCandidateCount === 1 ? '' : 's'} finalized.`,
})
- navigate(shortlistLocation(checkpoint.shortlistId))
+ onClose()
} catch (reason) {
const mapped = mapApiError(reason, 'protected')
@@ -242,7 +242,7 @@ export function SelectedCandidatesReviewModal({
{candidate.hasExistingActiveShortlist ? (
- {`Already shortlisted in ${candidate.existingActiveShortlistCount} active request${
+ {`Already shortlisted in ${candidate.existingActiveShortlistCount} other shortlist${
candidate.existingActiveShortlistCount === 1 ? '' : 's'
}`}
diff --git a/src/features/candidate-filtering/pages/CandidateFilteringPage.tsx b/src/features/candidate-filtering/pages/CandidateFilteringPage.tsx
index ffc9bbc..dffb922 100644
--- a/src/features/candidate-filtering/pages/CandidateFilteringPage.tsx
+++ b/src/features/candidate-filtering/pages/CandidateFilteringPage.tsx
@@ -24,7 +24,7 @@ export function CandidateFilteringPage() {
return (
diff --git a/src/features/candidate-filtering/tests/candidateResultsWorkspace.test.tsx b/src/features/candidate-filtering/tests/candidateResultsWorkspace.test.tsx
index 3b66a68..c2c11ae 100644
--- a/src/features/candidate-filtering/tests/candidateResultsWorkspace.test.tsx
+++ b/src/features/candidate-filtering/tests/candidateResultsWorkspace.test.tsx
@@ -130,7 +130,7 @@ describe('CandidateResultsWorkspace wireframe behavior', () => {
`/admin/students/${studentId}`,
)
expect(screen.getByRole('columnheader', { name: 'Cross-shortlist status' })).toBeInTheDocument()
- expect(screen.getByText('Already shortlisted in 2 active requests')).toBeInTheDocument()
+ expect(screen.getByText('Already shortlisted in 2 other shortlists')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'View skills for Ayesha Perera' })).toBeEnabled()
expect(
screen.queryByText(
@@ -273,5 +273,12 @@ describe('CandidateResultsWorkspace wireframe behavior', () => {
await waitFor(() => expect(finalizeCalls).toBe(2))
expect(addCalls).toBe(1)
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('dialog', { name: 'Review Selected Shortlist' }),
+ ).not.toBeInTheDocument(),
+ )
+ expect(screen.getByText('Shortlist allocation finalized')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Matching Students' })).toBeInTheDocument()
})
})
diff --git a/src/features/cv-builder/components/CvPreviewPanel.tsx b/src/features/cv-builder/components/CvPreviewPanel.tsx
index 02aa513..9b336d5 100644
--- a/src/features/cv-builder/components/CvPreviewPanel.tsx
+++ b/src/features/cv-builder/components/CvPreviewPanel.tsx
@@ -79,7 +79,30 @@ export function CvPreviewPanel({
)
}
+const previewStyles = `
+html{background:#eef0f3;color:#1c1c1c;font:14px/1.55 'Liberation Sans',Arial,Helvetica,sans-serif}
+body{margin:0;padding:40px 20px}
+.cv-document{max-width:760px;margin:0 auto;background:#fff;padding:48px 56px;border:1px solid #e2e2e2;box-shadow:0 1px 4px rgba(0,0,0,.08)}
+.cv-header{text-align:center;margin-bottom:18px;padding-bottom:14px;border-bottom:2px solid #1c385e}
+.cv-header h1{font-size:30px;margin:0 0 6px;letter-spacing:.4px}
+.cv-headline{font-style:italic;color:#444;margin:0 0 8px;font-size:14px}
+.cv-contact,.cv-links{margin:2px 0;font-size:12.5px;color:#333}
+.cv-links a{color:#1c385e;text-decoration:underline}
+section{margin-top:20px}
+section:first-of-type{margin-top:0}
+section>h2{font-size:13px;letter-spacing:1px;text-transform:uppercase;font-weight:700;color:#1c385e;margin:0 0 8px;padding-bottom:4px;border-bottom:1px solid #1c385e}
+article{margin-bottom:14px}
+article:last-child{margin-bottom:0}
+article>h3{font-size:14.5px;margin:0;font-weight:700}
+.cv-meta{margin:2px 0 6px;font-size:12.5px;font-style:italic;color:#555}
+p{margin:4px 0;font-size:13.5px}
+ul{margin:6px 0;padding-left:20px}
+li{margin:3px 0;font-size:13.5px}
+a{color:#1c385e;text-decoration:none;border-bottom:1px solid #ccc}
+strong{font-weight:700}
+`.replace(/\n/g, '')
+
export function buildPreviewDocument(htmlPreview: string) {
const sanitizedPreview = sanitizeCvHtml(htmlPreview)
- return `
${sanitizedPreview}`
+ return `
${sanitizedPreview}`
}
diff --git a/src/features/cv-builder/pages/CvBuilderPage.tsx b/src/features/cv-builder/pages/CvBuilderPage.tsx
index 4a40dec..5c521d2 100644
--- a/src/features/cv-builder/pages/CvBuilderPage.tsx
+++ b/src/features/cv-builder/pages/CvBuilderPage.tsx
@@ -272,15 +272,6 @@ export function CvBuilderPage() {
/>
-
-
+
+
)
diff --git a/src/features/eligible-students/api/eligibleStudentsApi.ts b/src/features/eligible-students/api/eligibleStudentsApi.ts
new file mode 100644
index 0000000..00c16d7
--- /dev/null
+++ b/src/features/eligible-students/api/eligibleStudentsApi.ts
@@ -0,0 +1,56 @@
+import { httpClient } from '../../../shared/api/httpClient'
+import {
+ eligibleStudentImportResultSchema,
+ eligibleStudentPagedResponseSchema,
+ eligibleStudentSchema,
+} from '../schemas/eligibleStudentSchemas'
+import type {
+ EligibleStudent,
+ EligibleStudentImportResult,
+ EligibleStudentQuery,
+ EligibleStudentRequest,
+ PagedResponse,
+} from '../types/eligibleStudentTypes'
+
+const basePath = '/admin/eligible-students'
+
+function listPath(query: EligibleStudentQuery) {
+ const parameters = new URLSearchParams({
+ page: String(query.page),
+ size: String(query.size),
+ sort: query.sort,
+ })
+ if (query.search.trim()) parameters.set('search', query.search.trim())
+ return `${basePath}?${parameters.toString()}`
+}
+
+export const eligibleStudentsApi = {
+ async list(query: EligibleStudentQuery, signal?: AbortSignal): Promise
> {
+ return eligibleStudentPagedResponseSchema.parse(
+ await httpClient(listPath(query), { signal }),
+ ) as PagedResponse
+ },
+ async create(values: EligibleStudentRequest): Promise {
+ return eligibleStudentSchema.parse(
+ await httpClient(basePath, { method: 'POST', body: values }),
+ ) as EligibleStudent
+ },
+ async update(id: string, values: EligibleStudentRequest): Promise {
+ return eligibleStudentSchema.parse(
+ await httpClient(`${basePath}/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ body: values,
+ }),
+ ) as EligibleStudent
+ },
+ async remove(id: string): Promise {
+ await httpClient(`${basePath}/${encodeURIComponent(id)}`, { method: 'DELETE' })
+ },
+ async importFile(file: File): Promise {
+ const body = new FormData()
+ body.set('file', file)
+ return eligibleStudentImportResultSchema.parse(
+ await httpClient(`${basePath}/import`, { method: 'POST', body }),
+ ) as EligibleStudentImportResult
+ },
+}
diff --git a/src/features/eligible-students/components/EligibleStudentForm.tsx b/src/features/eligible-students/components/EligibleStudentForm.tsx
new file mode 100644
index 0000000..e3c3ac2
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentForm.tsx
@@ -0,0 +1,202 @@
+import { useRef, useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FormField } from '../../../shared/components/forms/FormField'
+import { TextInput } from '../../../shared/components/forms/TextInput'
+import { Modal } from '../../../shared/components/overlays/Modal'
+import { Button } from '../../../shared/components/ui/Button'
+import { eligibleStudentFormSchema } from '../schemas/eligibleStudentSchemas'
+import type {
+ EligibleStudent,
+ EligibleStudentFormValues,
+ EligibleStudentRequest,
+} from '../types/eligibleStudentTypes'
+
+type Field = keyof EligibleStudentFormValues
+type FieldErrors = Partial>
+
+const emptyForm: EligibleStudentFormValues = {
+ indexNumber: '',
+ universityEmail: '',
+ fullName: '',
+ academicLevel: '',
+}
+
+function toFormValues(student: EligibleStudent): EligibleStudentFormValues {
+ return {
+ indexNumber: student.indexNumber,
+ universityEmail: student.universityEmail,
+ fullName: student.fullName,
+ academicLevel: String(student.academicLevel) as '3' | '4',
+ }
+}
+
+export function EligibleStudentForm({
+ item,
+ onCancel,
+ onSubmit,
+}: {
+ item?: EligibleStudent
+ onCancel: () => void
+ onSubmit: (values: EligibleStudentRequest) => Promise
+}) {
+ const [values, setValues] = useState(
+ item ? toFormValues(item) : emptyForm,
+ )
+ const [errors, setErrors] = useState({})
+ const [formError, setFormError] = useState()
+ const [isPending, setIsPending] = useState(false)
+ const indexRef = useRef(null)
+
+ const update = (field: F, value: EligibleStudentFormValues[F]) => {
+ setValues((current) => ({ ...current, [field]: value }))
+ setErrors((current) => ({ ...current, [field]: undefined }))
+ setFormError(undefined)
+ }
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setErrors({})
+ setFormError(undefined)
+ const parsed = eligibleStudentFormSchema.safeParse(values)
+ if (!parsed.success) {
+ const nextErrors: FieldErrors = {}
+ for (const issue of parsed.error.issues) {
+ const field = issue.path[0]
+ if (typeof field === 'string' && !nextErrors[field as Field]) {
+ nextErrors[field as Field] = issue.message
+ }
+ }
+ setErrors(nextErrors)
+ if (nextErrors.indexNumber) window.requestAnimationFrame(() => indexRef.current?.focus())
+ return
+ }
+
+ setIsPending(true)
+ try {
+ await onSubmit({
+ indexNumber: parsed.data.indexNumber.toUpperCase(),
+ universityEmail: parsed.data.universityEmail.toLowerCase(),
+ fullName: parsed.data.fullName,
+ academicLevel: Number(parsed.data.academicLevel) as 3 | 4,
+ })
+ } catch (reason) {
+ const mapped = mapApiError(reason, 'protected')
+ const nextErrors: FieldErrors = {}
+ for (const fieldError of mapped.fieldErrors) {
+ if (fieldError.field in values) {
+ nextErrors[fieldError.field as Field] = fieldError.message
+ }
+ }
+ setErrors(nextErrors)
+ setFormError(mapped.message)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const describedBy = (field: Field) => (errors[field] ? `eligible-student-${field}-error` : undefined)
+
+ return (
+
+
+
+ )
+}
diff --git a/src/features/eligible-students/components/EligibleStudentImportPanel.tsx b/src/features/eligible-students/components/EligibleStudentImportPanel.tsx
new file mode 100644
index 0000000..e455e47
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentImportPanel.tsx
@@ -0,0 +1,77 @@
+import { useRef, useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FileUploadField } from '../../../shared/components/forms/FileUploadField'
+import { Button } from '../../../shared/components/ui/Button'
+import { useEligibleStudentMutations } from '../hooks/useEligibleStudents'
+import type { EligibleStudentImportResult } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentImportPanel() {
+ const mutations = useEligibleStudentMutations()
+ const [file, setFile] = useState(null)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState()
+ const inputRef = useRef(null)
+
+ const submit = async () => {
+ if (!file) return
+ setError(undefined)
+ setResult(null)
+ try {
+ const response = await mutations.importFile.mutateAsync(file)
+ setResult(response)
+ setFile(null)
+ if (inputRef.current) inputRef.current.value = ''
+ } catch (reason) {
+ setError(mapApiError(reason, 'protected').message)
+ }
+ }
+
+ return (
+
+
Bulk Import
+
+ Columns: Index Number , University Email ,{' '}
+ Full Name , Academic Level (3 or 4). Duplicates are
+ skipped.
+
+
+ setFile(event.target.files?.[0] ?? null)}
+ ref={inputRef}
+ />
+ void submit()}
+ >
+ Import
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {result ? (
+
+
+ Imported {result.importedCount} of {result.totalRows} row
+ {result.totalRows === 1 ? '' : 's'}
+ {result.skippedCount > 0 ? ` — ${result.skippedCount} skipped.` : '.'}
+
+ {result.errors.length > 0 ? (
+
+ {result.errors.map((rowError) => (
+
+ Row {rowError.row}: {rowError.message}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/eligible-students/components/EligibleStudentsTable.tsx b/src/features/eligible-students/components/EligibleStudentsTable.tsx
new file mode 100644
index 0000000..a10d7f2
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentsTable.tsx
@@ -0,0 +1,61 @@
+import { StatusBadge } from '../../../shared/components/ui/StatusBadge'
+import { Button } from '../../../shared/components/ui/Button'
+import type { EligibleStudent } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentsTable({
+ disabled,
+ items,
+ onDelete,
+ onEdit,
+}: {
+ disabled: boolean
+ items: EligibleStudent[]
+ onDelete: (student: EligibleStudent) => void
+ onEdit: (student: EligibleStudent) => void
+}) {
+ return (
+
+
+ Eligible student roster
+
+
+ Index Number
+ Full Name
+ University Email
+ Level
+ Status
+ Actions
+
+
+
+ {items.map((student) => (
+
+ {student.indexNumber}
+ {student.fullName}
+ {student.universityEmail}
+ {student.academicLevel}
+
+
+ {student.registered ? 'Registered' : 'Not registered'}
+
+
+
+ onEdit(student)} variant="secondary">
+ Edit
+
+ onDelete(student)}
+ title={student.registered ? 'Registered students cannot be removed.' : undefined}
+ variant="secondary"
+ >
+ Delete
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/eligible-students/hooks/useEligibleStudents.ts b/src/features/eligible-students/hooks/useEligibleStudents.ts
new file mode 100644
index 0000000..6fc1149
--- /dev/null
+++ b/src/features/eligible-students/hooks/useEligibleStudents.ts
@@ -0,0 +1,46 @@
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { adminDashboardKeys } from '../../admin-dashboard/hooks/adminDashboardQueryKeys'
+import { eligibleStudentsApi } from '../api/eligibleStudentsApi'
+import type { EligibleStudentQuery, EligibleStudentRequest } from '../types/eligibleStudentTypes'
+
+const keys = {
+ all: ['admin', 'eligible-students'] as const,
+ list: (query: EligibleStudentQuery) => [...keys.all, 'list', query] as const,
+}
+
+export function useEligibleStudents(query: EligibleStudentQuery) {
+ return useQuery({
+ queryKey: keys.list(query),
+ queryFn: ({ signal }) => eligibleStudentsApi.list(query, signal),
+ placeholderData: keepPreviousData,
+ })
+}
+
+export function useEligibleStudentMutations() {
+ const queryClient = useQueryClient()
+ const refresh = () => {
+ void queryClient.invalidateQueries({ queryKey: keys.all })
+ // The Admin Dashboard's "Total Students" / "Registered Students" metrics are derived from the
+ // eligible-students roster, so they go stale the moment a roster entry is added or removed.
+ return queryClient.invalidateQueries({ queryKey: adminDashboardKeys.all })
+ }
+ return {
+ create: useMutation({
+ mutationFn: (values: EligibleStudentRequest) => eligibleStudentsApi.create(values),
+ onSuccess: refresh,
+ }),
+ update: useMutation({
+ mutationFn: ({ id, values }: { id: string; values: EligibleStudentRequest }) =>
+ eligibleStudentsApi.update(id, values),
+ onSuccess: refresh,
+ }),
+ remove: useMutation({
+ mutationFn: (id: string) => eligibleStudentsApi.remove(id),
+ onSuccess: refresh,
+ }),
+ importFile: useMutation({
+ mutationFn: (file: File) => eligibleStudentsApi.importFile(file),
+ onSuccess: refresh,
+ }),
+ }
+}
diff --git a/src/features/eligible-students/pages/EligibleStudentsPage.tsx b/src/features/eligible-students/pages/EligibleStudentsPage.tsx
new file mode 100644
index 0000000..af23a3a
--- /dev/null
+++ b/src/features/eligible-students/pages/EligibleStudentsPage.tsx
@@ -0,0 +1,175 @@
+import { useState } from 'react'
+import { useNotifications } from '../../../app/providers/NotificationProvider'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { PaginationBar } from '../../../shared/components/data/PaginationBar'
+import { SearchInput } from '../../../shared/components/data/SearchInput'
+import { EmptyState } from '../../../shared/components/feedback/EmptyState'
+import { ErrorState } from '../../../shared/components/feedback/ErrorState'
+import { LoadingBoundary } from '../../../shared/components/feedback/LoadingBoundary'
+import { PageHeader } from '../../../shared/components/layout/PageHeader'
+import { SectionCard } from '../../../shared/components/layout/SectionCard'
+import { ConfirmDialog } from '../../../shared/components/overlays/ConfirmDialog'
+import { Button } from '../../../shared/components/ui/Button'
+import { useDebouncedValue } from '../../../shared/hooks/useDebouncedValue'
+import { EligibleStudentForm } from '../components/EligibleStudentForm'
+import { EligibleStudentImportPanel } from '../components/EligibleStudentImportPanel'
+import { EligibleStudentsTable } from '../components/EligibleStudentsTable'
+import { useEligibleStudentMutations, useEligibleStudents } from '../hooks/useEligibleStudents'
+import { ELIGIBLE_STUDENTS_PAGE_SIZE } from '../types/eligibleStudentTypes'
+import type { EligibleStudent, EligibleStudentRequest } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentsPage() {
+ const { notify } = useNotifications()
+ const [search, setSearchValue] = useState('')
+ const [page, setPage] = useState(0)
+ const debouncedSearch = useDebouncedValue(search, 300)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const setSearch = (value: string) => {
+ setSearchValue(value)
+ setPage(0)
+ }
+
+ const query = useEligibleStudents({
+ page,
+ size: ELIGIBLE_STUDENTS_PAGE_SIZE,
+ sort: 'indexNumber,asc',
+ search: debouncedSearch,
+ })
+ const mutations = useEligibleStudentMutations()
+ const pending = mutations.create.isPending || mutations.update.isPending || mutations.remove.isPending
+
+ const save = async (values: EligibleStudentRequest) => {
+ const item =
+ editing === 'new'
+ ? await mutations.create.mutateAsync(values)
+ : await mutations.update.mutateAsync({ id: editing!.id, values })
+ notify({
+ tone: 'success',
+ title: editing === 'new' ? 'Eligible student added' : 'Eligible student updated',
+ message: `${item.fullName} was saved.`,
+ })
+ setEditing(null)
+ }
+
+ const remove = async () => {
+ if (!deleting) return
+ try {
+ await mutations.remove.mutateAsync(deleting.id)
+ notify({
+ tone: 'success',
+ title: 'Eligible student removed',
+ message: `${deleting.fullName} was removed from the roster.`,
+ })
+ setDeleting(null)
+ } catch (error) {
+ const mapped = mapApiError(error, 'protected')
+ notify({ tone: 'error', title: 'Unable to remove eligible student', message: mapped.message })
+ }
+ }
+
+ const items = query.data?.items ?? []
+ const mappedError = query.isError ? mapApiError(query.error, 'protected') : null
+
+ return (
+
+
+
+
+
+
+
+
Roster
+ person_add}
+ onClick={() => setEditing('new')}
+ >
+ Add Student
+
+
+ setSearch(event.target.value)}
+ placeholder="Search by index number, name, or email"
+ value={search}
+ />
+
+ {mappedError ? (
+ void query.refetch()}
+ title="Eligible students unavailable"
+ />
+ ) : items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {query.data ? (
+
+ ) : null}
+ >
+ )}
+
+
+
+ {editing ? (
+ setEditing(null)}
+ onSubmit={save}
+ />
+ ) : null}
+
+ {deleting ? (
+ setDeleting(null)}
+ title="Remove Eligible Student"
+ >
+
+ Remove {deleting.fullName} ({deleting.indexNumber}) from the roster?
+ They won’t be able to register.
+
+
+ setDeleting(null)}
+ variant="secondary"
+ >
+ Cancel
+
+ void remove()}>
+ Remove Student
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/eligible-students/schemas/eligibleStudentSchemas.ts b/src/features/eligible-students/schemas/eligibleStudentSchemas.ts
new file mode 100644
index 0000000..8526d3b
--- /dev/null
+++ b/src/features/eligible-students/schemas/eligibleStudentSchemas.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod'
+
+const timestampSchema = z.string().datetime({ offset: true })
+
+export const eligibleStudentSchema = z
+ .object({
+ id: z.string().uuid(),
+ indexNumber: z.string().min(1).max(32),
+ universityEmail: z.string().email().max(254),
+ fullName: z.string().min(1).max(160),
+ academicLevel: z.union([z.literal(3), z.literal(4)]),
+ active: z.boolean(),
+ registered: z.boolean(),
+ createdAt: timestampSchema,
+ updatedAt: timestampSchema,
+ })
+ .strict()
+
+export const pageMetadataSchema = z
+ .object({
+ page: z.number().int().nonnegative(),
+ size: z.number().int().min(1).max(500),
+ totalElements: z.number().int().nonnegative(),
+ totalPages: z.number().int().nonnegative(),
+ sort: z.string(),
+ })
+ .strict()
+
+export const eligibleStudentPagedResponseSchema = z
+ .object({ items: z.array(eligibleStudentSchema), page: pageMetadataSchema })
+ .strict()
+
+export const eligibleStudentImportResultSchema = z
+ .object({
+ totalRows: z.number().int().nonnegative(),
+ importedCount: z.number().int().nonnegative(),
+ skippedCount: z.number().int().nonnegative(),
+ errors: z.array(z.object({ row: z.number().int(), message: z.string() })),
+ })
+ .strict()
+
+const indexNumberPattern = /^[A-Za-z]{2}\/[0-9]{4}\/[0-9]{5}$/
+
+export const eligibleStudentFormSchema = z.object({
+ indexNumber: z
+ .string()
+ .trim()
+ .regex(indexNumberPattern, 'Use the format CS/2022/00123.'),
+ universityEmail: z.string().trim().email('Enter a valid email address.').max(254),
+ fullName: z.string().trim().min(1, 'Full name is required.').max(160),
+ academicLevel: z.union([z.literal('3'), z.literal('4')], {
+ errorMap: () => ({ message: 'Select academic level 3 or 4.' }),
+ }),
+})
diff --git a/src/features/eligible-students/types/eligibleStudentTypes.ts b/src/features/eligible-students/types/eligibleStudentTypes.ts
new file mode 100644
index 0000000..54966e5
--- /dev/null
+++ b/src/features/eligible-students/types/eligibleStudentTypes.ts
@@ -0,0 +1,53 @@
+export const ELIGIBLE_STUDENTS_PAGE_SIZE = 10
+
+export type EligibleStudent = {
+ id: string
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: 3 | 4
+ active: boolean
+ registered: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+export type EligibleStudentQuery = {
+ page: number
+ size: number
+ sort: string
+ search: string
+}
+
+export type PageMetadata = {
+ page: number
+ size: number
+ totalElements: number
+ totalPages: number
+ sort: string
+}
+
+export type PagedResponse = { items: T[]; page: PageMetadata }
+
+export type EligibleStudentFormValues = {
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: '3' | '4' | ''
+}
+
+export type EligibleStudentRequest = {
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: 3 | 4
+}
+
+export type EligibleStudentImportRowError = { row: number; message: string }
+
+export type EligibleStudentImportResult = {
+ totalRows: number
+ importedCount: number
+ skippedCount: number
+ errors: EligibleStudentImportRowError[]
+}
diff --git a/src/features/home/components/GatewayIntro.tsx b/src/features/home/components/GatewayIntro.tsx
index 43fa267..071c516 100644
--- a/src/features/home/components/GatewayIntro.tsx
+++ b/src/features/home/components/GatewayIntro.tsx
@@ -1,32 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GatewayCaption } from '../data/gatewayCaptions'
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
-import { LogoDrawReveal } from './LogoDrawReveal'
-type IntroPhase = 'logo' | 'slides' | 'exit'
+type IntroPhase = 'playing' | 'exit'
-type GatewayIntroProps = {
- captions: readonly GatewayCaption[]
+export type GatewayIntroProps = {
+ captions?: readonly GatewayCaption[]
onComplete: () => void
+ videoSrc?: string
}
export const gatewayIntroTiming = {
- logoRevealMs: 2000,
- slideHoldMs: 1250,
- exitMs: 420,
- reducedMotionMs: 180,
+ exitMs: 500,
+ reducedMotionMs: 150,
} as const
-export function GatewayIntro({ captions, onComplete }: GatewayIntroProps) {
+const DEFAULT_VIDEO_SRC = '/videos/Required%20Intro%20video.mp4'
+
+export function GatewayIntro({ onComplete, videoSrc = DEFAULT_VIDEO_SRC }: GatewayIntroProps) {
const prefersReducedMotion = usePrefersReducedMotion()
+ const videoRef = useRef(null)
const skipButtonRef = useRef(null)
const hasCompletedRef = useRef(false)
- const [phase, setPhase] = useState('logo')
- const [activeSlide, setActiveSlide] = useState(0)
+ const [phase, setPhase] = useState('playing')
const completeIntro = useCallback(() => {
if (hasCompletedRef.current) return
-
hasCompletedRef.current = true
onComplete()
}, [onComplete])
@@ -38,115 +37,95 @@ export function GatewayIntro({ captions, onComplete }: GatewayIntroProps) {
}
setPhase('exit')
+ const timer = window.setTimeout(completeIntro, gatewayIntroTiming.exitMs)
+ return () => window.clearTimeout(timer)
}, [completeIntro, prefersReducedMotion])
+ // Focus skip button on mount for immediate keyboard accessibility
useEffect(() => {
skipButtonRef.current?.focus({ preventScroll: true })
}, [])
+ // Respect reduced motion: finish immediately
+ useEffect(() => {
+ if (prefersReducedMotion) {
+ const timer = window.setTimeout(completeIntro, gatewayIntroTiming.reducedMotionMs)
+ return () => window.clearTimeout(timer)
+ }
+ }, [completeIntro, prefersReducedMotion])
+
+ // Keyboard shortcut: Escape to skip
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
requestExit()
}
-
- if (event.key === 'Tab') {
- event.preventDefault()
- skipButtonRef.current?.focus({ preventScroll: true })
- }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [requestExit])
+ // Initiate autoplay safely
useEffect(() => {
- if (prefersReducedMotion) {
- const timer = window.setTimeout(completeIntro, gatewayIntroTiming.reducedMotionMs)
- return () => window.clearTimeout(timer)
- }
-
- if (phase === 'logo') {
- const timer = window.setTimeout(() => {
- if (captions.length === 0) {
- setPhase('exit')
- return
- }
-
- setPhase('slides')
- }, gatewayIntroTiming.logoRevealMs)
-
- return () => window.clearTimeout(timer)
- }
-
- if (phase === 'slides') {
- const timer = window.setTimeout(() => {
- if (activeSlide < captions.length - 1) {
- setActiveSlide((currentSlide) => currentSlide + 1)
- return
- }
-
- setPhase('exit')
- }, gatewayIntroTiming.slideHoldMs)
-
- return () => window.clearTimeout(timer)
+ const video = videoRef.current
+ if (!video || prefersReducedMotion) return
+
+ try {
+ const playPromise = video.play()
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch(() => {
+ // Autoplay blocked by browser policy; remains ready for user interaction/skip
+ })
+ }
+ } catch {
+ // Safe fallback
}
-
- const timer = window.setTimeout(completeIntro, gatewayIntroTiming.exitMs)
- return () => window.clearTimeout(timer)
- }, [activeSlide, captions.length, completeIntro, phase, prefersReducedMotion])
+ }, [prefersReducedMotion])
return (
-
- Skip intro
-
-
-
-
-
-
-
-
-
-
CV Management System
-
- {captions.map((caption, index) => {
- const offset = (index - activeSlide) * 112
- const isActive = phase === 'slides' && index === activeSlide
-
- return (
-
- {caption.text}
-
- )
- })}
-
-
-
- {captions.map((caption, index) => (
-
- ))}
-
+
+
+
+
+
+
+ Skip Intro
+
+ ESC
+
+
+ arrow_forward
+
+
)
diff --git a/src/features/home/components/TypewriterText.tsx b/src/features/home/components/TypewriterText.tsx
index 383de23..f2c3fc2 100644
--- a/src/features/home/components/TypewriterText.tsx
+++ b/src/features/home/components/TypewriterText.tsx
@@ -8,16 +8,16 @@ type TypewriterTextProps = {
className?: string
}
-const fullCaptionHoldMs = 1500
-const betweenCaptionPauseMs = 260
-const reducedMotionCaptionHoldMs = 2800
-const deletingDelayMs = 26
+const fullCaptionHoldMs = 2600
+const betweenCaptionPauseMs = 500
+const reducedMotionCaptionHoldMs = 3200
+const deletingDelayMs = 45
function getTypingDelay(character: string) {
- if (/[.!?]/.test(character)) return 180
- if (/[,;:]/.test(character)) return 110
- if (character === ' ') return 34
- return 58
+ if (/[.!?]/.test(character)) return 320
+ if (/[,;:]/.test(character)) return 200
+ if (character === ' ') return 80
+ return 95
}
export function TypewriterText({ captions, className = '' }: TypewriterTextProps) {
diff --git a/src/features/home/pages/HomePage.tsx b/src/features/home/pages/HomePage.tsx
index 7320eeb..415fad3 100644
--- a/src/features/home/pages/HomePage.tsx
+++ b/src/features/home/pages/HomePage.tsx
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { GatewayIntro } from '../components/GatewayIntro'
-import { LogoDrawReveal } from '../components/LogoDrawReveal'
import { TypewriterText } from '../components/TypewriterText'
import { gatewayCaptions, gatewayCaptionTexts } from '../data/gatewayCaptions'
@@ -41,7 +40,13 @@ export function HomePage() {
-
+
@@ -67,7 +72,6 @@ export function HomePage() {
-
Secure role-based access
Select your role
diff --git a/src/features/home/styles/gateway.css b/src/features/home/styles/gateway.css
index 9124e39..4e68546 100644
--- a/src/features/home/styles/gateway.css
+++ b/src/features/home/styles/gateway.css
@@ -3,41 +3,16 @@
position: fixed;
inset: 0;
z-index: 1000;
- display: grid;
- place-items: center;
min-height: 100dvh;
+ width: 100vw;
overflow: hidden;
- background:
- radial-gradient(circle at 18% 18%, rgb(29 78 216 / 24%), transparent 34%),
- radial-gradient(circle at 82% 78%, rgb(30 64 175 / 20%), transparent 38%), var(--gateway-bg);
+ background: #020617;
}
-.gateway-v2-preload-decoration {
+.gateway-v2-preload-bg {
position: absolute;
inset: 0;
- opacity: 0.2;
- background-image:
- linear-gradient(rgb(148 163 184 / 12%) 1px, transparent 1px),
- linear-gradient(90deg, rgb(148 163 184 / 12%) 1px, transparent 1px);
- background-size: 56px 56px;
- mask-image: linear-gradient(to bottom, transparent, black 18%, black 75%, transparent);
-}
-
-.gateway-v2-preload-logo-stage {
- position: relative;
- z-index: 1;
- width: min(560px, 72vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
-}
-
-.gateway-v2-preload-logo {
- display: block;
- width: 100%;
- height: auto;
- filter: invert(1);
- mix-blend-mode: screen;
+ background: radial-gradient(circle at 50% 50%, rgb(30 58 138 / 20%), transparent 70%), #020617;
}
.gateway-v2-visually-hidden {
@@ -53,175 +28,124 @@
}
.gateway-v2-page,
-.gateway-v2-intro {
+.gateway-v2-intro-cinema {
scrollbar-width: none;
-ms-overflow-style: none;
}
.gateway-v2-page::-webkit-scrollbar,
-.gateway-v2-intro::-webkit-scrollbar {
+.gateway-v2-intro-cinema::-webkit-scrollbar {
display: none;
}
-/* Intro sequence */
-.gateway-v2-intro {
+/* Full-Bleed Cinematic Intro Experience */
+.gateway-v2-intro-cinema {
position: fixed;
inset: 0;
+ width: 100vw;
+ height: 100dvh;
z-index: 1000;
- display: grid;
- place-items: center;
- min-height: 100dvh;
overflow: hidden;
- padding: clamp(24px, 5vw, 72px);
- background:
- radial-gradient(circle at 18% 18%, rgb(29 78 216 / 24%), transparent 34%),
- radial-gradient(circle at 82% 78%, rgb(30 64 175 / 20%), transparent 38%), var(--gateway-bg);
- color: var(--sidebar-text);
+ background: #020617;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ opacity 500ms cubic-bezier(0.16, 1, 0.3, 1),
+ transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
-.gateway-v2-intro::after {
- content: '';
+.gateway-v2-intro-cinema-video {
position: absolute;
inset: 0;
- pointer-events: none;
- background: linear-gradient(180deg, transparent 68%, rgb(2 6 23 / 46%) 100%);
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ display: block;
}
-.gateway-v2-intro-decoration {
+.gateway-v2-intro-cinema-scrim {
position: absolute;
inset: 0;
- opacity: 0.2;
pointer-events: none;
- background-image:
- linear-gradient(rgb(148 163 184 / 12%) 1px, transparent 1px),
- linear-gradient(90deg, rgb(148 163 184 / 12%) 1px, transparent 1px);
- background-size: 56px 56px;
- mask-image: linear-gradient(to bottom, transparent, black 18%, black 75%, transparent);
+ background:
+ radial-gradient(circle at 50% 50%, transparent 40%, rgb(2 6 23 / 25%) 100%),
+ linear-gradient(
+ 180deg,
+ rgb(2 6 23 / 35%) 0%,
+ transparent 18%,
+ transparent 82%,
+ rgb(2 6 23 / 50%) 100%
+ );
}
-.gateway-v2-intro-skip {
+.gateway-v2-intro-cinema-overlay {
position: absolute;
- top: 18px;
- right: 18px;
- z-index: 4;
- min-height: 42px;
- color: var(--sidebar-text);
- background: rgb(15 23 42 / 76%);
- border-color: var(--sidebar-border);
- backdrop-filter: blur(12px);
-}
-
-.gateway-v2-intro-logo-stage {
- position: relative;
- z-index: 2;
- width: min(560px, 72vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
- transition:
- opacity 420ms var(--motion-standard),
- transform 520ms var(--motion-standard),
- filter 520ms var(--motion-standard);
-}
-
-.gateway-v2-intro-logo {
- display: block;
- width: 100%;
- height: auto;
- user-select: none;
- pointer-events: none;
-}
-
-.gateway-v2-intro--slides .gateway-v2-intro-logo-stage {
- opacity: 0;
- filter: blur(8px);
- transform: translateY(-18vh);
- pointer-events: none;
+ top: max(20px, env(safe-area-inset-top, 20px));
+ right: max(24px, env(safe-area-inset-right, 24px));
+ z-index: 10;
+ display: flex;
+ align-items: center;
}
-.gateway-v2-intro-slides {
- position: absolute;
- z-index: 3;
- width: min(920px, calc(100% - 48px));
- display: grid;
- justify-items: center;
- gap: 18px;
- opacity: 0;
- transform: translateY(48px);
- pointer-events: none;
+.gateway-v2-intro-cinema-skip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 42px;
+ padding: 8px 18px;
+ border-radius: var(--radius-pill);
+ background: rgb(15 23 42 / 65%);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgb(255 255 255 / 15%);
+ color: #f8fafc;
+ font-size: 0.875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ box-shadow: 0 4px 20px rgb(0 0 0 / 40%);
transition:
- opacity 420ms var(--motion-standard),
- transform 520ms var(--motion-standard);
-}
-
-.gateway-v2-intro--slides .gateway-v2-intro-slides {
- opacity: 1;
- transform: translateY(0);
-}
-
-.gateway-v2-intro-kicker {
- margin: 0;
- color: var(--sidebar-accent);
- font-size: var(--font-size-sm);
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: 0.12em;
-}
-
-.gateway-v2-intro-slide-viewport {
- position: relative;
- width: 100%;
- height: clamp(120px, 20vw, 220px);
- overflow: hidden;
+ background-color var(--motion-duration-fast) ease,
+ border-color var(--motion-duration-fast) ease,
+ color var(--motion-duration-fast) ease,
+ transform var(--motion-duration-fast) ease,
+ box-shadow var(--motion-duration-fast) ease;
}
-.gateway-v2-intro-slide {
- position: absolute;
- inset: 0;
- display: grid;
- place-items: center;
- margin: 0;
- padding: 0 24px;
- color: var(--sidebar-text);
- font-size: clamp(2rem, 6vw, 5.4rem);
- font-weight: 700;
- line-height: 1.02;
- letter-spacing: -0.035em;
- text-align: center;
- transition:
- transform 540ms var(--motion-standard),
- opacity 300ms var(--motion-standard);
- will-change: transform, opacity;
+.gateway-v2-intro-cinema-skip:hover,
+.gateway-v2-intro-cinema-skip:focus-visible {
+ background: rgb(30 41 59 / 88%);
+ border-color: var(--sidebar-accent);
+ color: #fff;
+ transform: translateY(-1px);
+ box-shadow: 0 6px 24px rgb(59 130 246 / 35%);
}
-.gateway-v2-intro-progress {
- display: flex;
+.gateway-v2-intro-cinema-skip-key {
+ display: inline-flex;
align-items: center;
- gap: 9px;
-}
-
-.gateway-v2-intro-progress span {
- width: 9px;
- height: 9px;
- border-radius: var(--radius-pill);
- background: var(--sidebar-border);
- transition:
- width var(--motion-duration-medium) var(--motion-standard),
- background-color var(--motion-duration-medium) var(--motion-standard);
+ justify-content: center;
+ padding: 2px 6px;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ border-radius: 4px;
+ background: rgb(255 255 255 / 18%);
+ color: rgb(241 245 249);
+ letter-spacing: 0.05em;
+ line-height: 1;
}
-.gateway-v2-intro-progress span.is-active {
- width: 34px;
- background: var(--sidebar-accent);
+.gateway-v2-intro-cinema-skip-icon {
+ font-size: 18px;
+ color: var(--sidebar-accent);
}
-.gateway-v2-intro--exit {
+.gateway-v2-intro-cinema--exit {
opacity: 0;
transform: scale(1.015);
pointer-events: none;
- transition:
- opacity 420ms var(--motion-standard),
- transform 420ms var(--motion-standard);
}
/* Final gateway landing */
@@ -242,9 +166,13 @@
height: 100dvh;
overflow-y: auto;
display: flex;
- align-items: center;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
overflow-x: hidden;
- padding: clamp(56px, 7vw, 112px);
+ padding: clamp(48px, 6.5vh, 72px) clamp(32px, 5vw, 64px) clamp(48px, 6vh, 80px)
+ clamp(32px, 5vw, 112px);
+ gap: clamp(32px, 5vh, 56px);
}
.gateway-v2-hero-bg {
@@ -281,59 +209,37 @@
left: -64px;
}
-.gateway-v2-hero-content {
- position: relative;
- z-index: 2;
- width: min(760px, 100%);
-}
-
.gateway-v2-brand-lockup {
- position: absolute;
- top: clamp(32px, 5vw, 64px);
- left: clamp(32px, 5vw, 112px);
+ position: relative;
z-index: 10;
- width: min(196px, 46vw);
- aspect-ratio: 16 / 9;
- display: grid;
- place-items: center;
- overflow: hidden;
- mix-blend-mode: screen;
+ display: flex;
+ align-items: center;
+ margin: 0;
}
-.gateway-v2-logo {
+.gateway-v2-static-logo {
display: block;
- width: 100%;
+ width: clamp(120px, 16vw, 165px);
height: auto;
+ object-fit: contain;
+ filter: drop-shadow(0 4px 20px rgb(59 130 246 / 24%));
+ user-select: none;
+ pointer-events: none;
+ transition: transform var(--motion-duration-medium) var(--motion-standard);
}
-.logo-draw-reveal {
- overflow: visible;
-}
-
-.logo-draw-reveal__mark {
- transform-origin: center;
-}
-
-.logo-draw-reveal__mark-stroke {
- fill: none;
- stroke: var(--sidebar-text);
- stroke-dasharray: 1000;
- stroke-dashoffset: 1000;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-width: 220px;
-}
-
-.logo-draw-reveal--once .logo-draw-reveal__mark-stroke {
- animation: gatewayV2DrawLogo 2000ms var(--motion-standard) both;
-}
-
-.logo-draw-reveal--loop .logo-draw-reveal__mark {
- animation: gatewayV2DrawLoopVisibility 6000ms var(--motion-standard) infinite;
+.gateway-v2-static-logo:hover {
+ transform: scale(1.03);
}
-.logo-draw-reveal--loop .logo-draw-reveal__mark-stroke {
- animation: gatewayV2DrawLogo 6000ms var(--motion-standard) infinite;
+.gateway-v2-hero-content {
+ position: relative;
+ z-index: 2;
+ width: min(760px, 100%);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
}
.gateway-v2-eyebrow,
@@ -350,10 +256,12 @@
max-width: 840px;
margin: 0;
color: var(--sidebar-text);
- font-size: clamp(2.6rem, 7vw, 5.9rem);
+ font-size: clamp(2.35rem, 5.4vw, 4.8rem);
font-weight: 700;
- line-height: 0.96;
- letter-spacing: -0.045em;
+ line-height: 1.06;
+ letter-spacing: -0.035em;
+ min-height: 3.5em;
+ text-align: left;
}
.gateway-v2-typewriter,
@@ -380,8 +288,8 @@
overflow-x: hidden;
display: flex;
flex-direction: column;
- gap: clamp(24px, 4vw, 40px);
- padding: clamp(48px, 6vw, 88px);
+ gap: clamp(16px, 2.5vw, 28px);
+ padding: clamp(28px, 4vw, 56px);
background: linear-gradient(180deg, var(--gateway-surface) 0%, var(--gateway-bg) 100%);
}
@@ -395,14 +303,14 @@
.gateway-v2-access-header {
display: grid;
justify-items: start;
- gap: 14px;
+ gap: 10px;
flex-shrink: 0;
}
.gateway-v2-access-header h2 {
margin: 0;
color: var(--sidebar-text);
- font-size: clamp(2rem, 4vw, 3.2rem);
+ font-size: clamp(1.7rem, 3.2vw, 2.6rem);
font-weight: 900;
line-height: 1.05;
text-transform: uppercase;
@@ -429,13 +337,13 @@
}
.gateway-v2-card {
- min-height: 170px;
+ min-height: 140px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
- gap: 12px;
- padding: clamp(24px, 3vw, 34px);
+ gap: 10px;
+ padding: clamp(18px, 2.4vw, 26px);
background: var(--gateway-surface);
text-align: left;
transition:
@@ -452,21 +360,21 @@
}
.gateway-v2-card .material-symbols-outlined {
- width: 56px;
- height: 56px;
+ width: 48px;
+ height: 48px;
display: grid;
place-items: center;
margin-bottom: 2px;
border-radius: 50%;
background: var(--sidebar-surface);
color: var(--sidebar-accent);
- font-size: 31px;
+ font-size: 27px;
}
.gateway-v2-card h3 {
margin: 0;
color: var(--sidebar-text);
- font-size: clamp(1.55rem, 2.6vw, 2.1rem);
+ font-size: clamp(1.3rem, 2.1vw, 1.75rem);
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
@@ -542,28 +450,35 @@
}
.gateway-v2-hero {
- min-height: 52dvh;
+ min-height: 40dvh;
height: auto;
overflow-y: visible;
- padding: 88px 24px 48px;
+ padding: 32px 24px 28px;
+ gap: 24px;
+ align-items: flex-start;
+ }
+
+ .gateway-v2-static-logo {
+ width: clamp(100px, 28vw, 130px);
}
.gateway-v2-hero h1 {
- font-size: clamp(2.35rem, 12vw, 3.6rem);
+ font-size: clamp(2.1rem, 9vw, 3.2rem);
+ min-height: 3.2em;
}
.gateway-v2-access {
min-height: auto;
height: auto;
overflow-y: visible;
- gap: 24px;
- padding: 40px 24px 64px;
+ gap: 18px;
+ padding: 28px 24px 48px;
}
.gateway-v2-card {
min-height: auto;
- gap: 16px;
- padding: 24px;
+ gap: 12px;
+ padding: 20px;
}
.gateway-v2-card .material-symbols-outlined {
@@ -580,37 +495,30 @@
}
@media (max-width: 560px) {
- .gateway-v2-intro {
- padding: 20px;
- }
-
- .gateway-v2-intro-skip {
- top: 14px;
- right: 14px;
+ .gateway-v2-intro-cinema-overlay {
+ top: max(14px, env(safe-area-inset-top, 14px));
+ right: max(14px, env(safe-area-inset-right, 14px));
}
- .gateway-v2-intro-logo-stage {
- width: min(88vw, 420px);
+ .gateway-v2-intro-cinema-skip {
+ min-height: 38px;
+ padding: 6px 14px;
+ font-size: 0.8125rem;
+ gap: 6px;
}
- .gateway-v2-intro-slides {
- width: calc(100% - 32px);
+ .gateway-v2-intro-cinema-skip-key {
+ display: none;
}
- .gateway-v2-intro-slide {
- padding: 0 8px;
- font-size: clamp(1.8rem, 10vw, 3.2rem);
+ .gateway-v2-intro-cinema-skip-icon {
+ font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
- .logo-draw-reveal__mark-stroke,
- .logo-draw-reveal__mark {
- animation: none;
- }
-
- .logo-draw-reveal__mark-stroke {
- stroke-dashoffset: 0;
+ .gateway-v2-static-logo {
+ transition: none;
}
.gateway-v2-typewriter-cursor {
diff --git a/src/features/home/tests/GatewayIntro.test.tsx b/src/features/home/tests/GatewayIntro.test.tsx
index a4cebe6..25a8d4b 100644
--- a/src/features/home/tests/GatewayIntro.test.tsx
+++ b/src/features/home/tests/GatewayIntro.test.tsx
@@ -1,55 +1,85 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GatewayIntro, gatewayIntroTiming } from '../components/GatewayIntro'
-import { gatewayCaptions } from '../data/gatewayCaptions'
describe('GatewayIntro', () => {
beforeEach(() => {
vi.useFakeTimers()
+ window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined)
+ window.HTMLMediaElement.prototype.pause = vi.fn()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
+ vi.restoreAllMocks()
})
- it('runs the logo, slide, and exit phases in sequence', () => {
+ it('renders the full-screen cinematic video and minimalist skip control', () => {
const onComplete = vi.fn()
- const { container } = render(
- ,
- )
- const intro = container.querySelector('.gateway-v2-intro')
+ const { container } = render( )
- expect(intro).toHaveAttribute('data-phase', 'logo')
- expect(container.querySelector('.logo-draw-reveal--once')).toBeInTheDocument()
- expect(container.querySelectorAll('.logo-draw-reveal__mark-stroke')).toHaveLength(1)
- expect(container.querySelector('.logo-draw-reveal__trace')).not.toBeInTheDocument()
+ const intro = container.querySelector('.gateway-v2-intro-cinema')
+ expect(intro).toHaveAttribute('data-phase', 'playing')
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.logoRevealMs)
- })
+ const video = container.querySelector('video')
+ expect(video).toBeInTheDocument()
+ expect(video).toHaveAttribute('src', '/videos/Required%20Intro%20video.mp4')
+ expect(video).toHaveProperty('muted', true)
+ expect(video).toHaveProperty('autoplay', true)
+ expect(video).toHaveProperty('playsInline', true)
- expect(intro).toHaveAttribute('data-phase', 'slides')
- expect(intro).toHaveAttribute('data-active-slide', '0')
+ expect(screen.getByRole('button', { name: /skip intro/i })).toBeInTheDocument()
+ })
- act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
- })
+ it('automatically finishes when the video ends', () => {
+ const onComplete = vi.fn()
+ const { container } = render( )
- expect(intro).toHaveAttribute('data-active-slide', '1')
+ const video = container.querySelector('video') as HTMLVideoElement
+ fireEvent.ended(video)
+
+ expect(container.querySelector('.gateway-v2-intro-cinema')).toHaveAttribute(
+ 'data-phase',
+ 'exit',
+ )
+ expect(onComplete).not.toHaveBeenCalled()
act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
+ vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
})
- expect(intro).toHaveAttribute('data-active-slide', '2')
+ expect(onComplete).toHaveBeenCalledOnce()
+ })
+
+ it('allows the sequence to be skipped via skip button', () => {
+ const onComplete = vi.fn()
+ const { container } = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: /skip intro/i }))
+
+ expect(container.querySelector('.gateway-v2-intro-cinema')).toHaveAttribute(
+ 'data-phase',
+ 'exit',
+ )
act(() => {
- vi.advanceTimersByTime(gatewayIntroTiming.slideHoldMs)
+ vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
})
- expect(intro).toHaveAttribute('data-phase', 'exit')
- expect(onComplete).not.toHaveBeenCalled()
+ expect(onComplete).toHaveBeenCalledOnce()
+ })
+
+ it('allows the sequence to be skipped via Escape key', () => {
+ const onComplete = vi.fn()
+ const { container } = render( )
+
+ fireEvent.keyDown(window, { key: 'Escape' })
+
+ expect(container.querySelector('.gateway-v2-intro-cinema')).toHaveAttribute(
+ 'data-phase',
+ 'exit',
+ )
act(() => {
vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
@@ -58,15 +88,17 @@ describe('GatewayIntro', () => {
expect(onComplete).toHaveBeenCalledOnce()
})
- it('allows the sequence to be skipped', () => {
+ it('gracefully exits if the video encounters an error', () => {
const onComplete = vi.fn()
- const { container } = render(
- ,
- )
+ const { container } = render( )
- fireEvent.click(screen.getByRole('button', { name: 'Skip intro' }))
+ const video = container.querySelector('video') as HTMLVideoElement
+ fireEvent.error(video)
- expect(container.querySelector('.gateway-v2-intro')).toHaveAttribute('data-phase', 'exit')
+ expect(container.querySelector('.gateway-v2-intro-cinema')).toHaveAttribute(
+ 'data-phase',
+ 'exit',
+ )
act(() => {
vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
diff --git a/src/features/home/tests/HomePage.test.tsx b/src/features/home/tests/HomePage.test.tsx
index 448ae4d..fd8f53a 100644
--- a/src/features/home/tests/HomePage.test.tsx
+++ b/src/features/home/tests/HomePage.test.tsx
@@ -7,11 +7,14 @@ import { HomePage } from '../pages/HomePage'
describe('HomePage', () => {
beforeEach(() => {
vi.useFakeTimers()
+ window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined)
+ window.HTMLMediaElement.prototype.pause = vi.fn()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
+ vi.restoreAllMocks()
})
it('reveals the gateway after the intro finishes', () => {
@@ -26,16 +29,19 @@ describe('HomePage', () => {
expect(screen.getByRole('region', { name: 'Gateway introduction' })).toBeInTheDocument()
expect(gateway).toHaveAttribute('aria-hidden', 'true')
- fireEvent.click(screen.getByRole('button', { name: 'Skip intro' }))
+ fireEvent.click(screen.getByRole('button', { name: /skip intro/i }))
act(() => {
vi.advanceTimersByTime(gatewayIntroTiming.exitMs)
})
expect(screen.queryByRole('region', { name: 'Gateway introduction' })).not.toBeInTheDocument()
- expect(gateway).toHaveAttribute('aria-hidden', 'false')
+ expect(screen.getByRole('img', { name: 'University logo' })).toHaveAttribute(
+ 'src',
+ '/assets/cv-logo.png',
+ )
expect(screen.getByRole('img', { name: 'University logo' })).toHaveClass(
- 'logo-draw-reveal--loop',
+ 'gateway-v2-static-logo',
)
expect(screen.getByRole('link', { name: 'Register' })).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: 'Login' })).toHaveLength(2)
diff --git a/src/features/student-auth/components/StudentCreatePasswordForm.tsx b/src/features/student-auth/components/StudentCreatePasswordForm.tsx
index 5a68215..bd335ab 100644
--- a/src/features/student-auth/components/StudentCreatePasswordForm.tsx
+++ b/src/features/student-auth/components/StudentCreatePasswordForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FormField } from '../../../shared/components/forms/FormField'
-import { TextInput } from '../../../shared/components/forms/TextInput'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { Button } from '../../../shared/components/ui/Button'
import {
flattenZodErrors,
@@ -45,13 +45,12 @@ export function StudentCreatePasswordForm({
Use at least 8 characters with uppercase, lowercase, number, and special character.
-
setValues((current) => ({ ...current, newPassword: event.target.value }))
}
- type="password"
value={values.newPassword}
/>
@@ -60,13 +59,12 @@ export function StudentCreatePasswordForm({
htmlFor="student-confirm-password"
label="Confirm New Password"
>
-
setValues((current) => ({ ...current, confirmPassword: event.target.value }))
}
- type="password"
value={values.confirmPassword}
/>
diff --git a/src/features/student-auth/components/StudentLoginForm.tsx b/src/features/student-auth/components/StudentLoginForm.tsx
index c5452d7..2c4898f 100644
--- a/src/features/student-auth/components/StudentLoginForm.tsx
+++ b/src/features/student-auth/components/StudentLoginForm.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { FormField } from '../../../shared/components/forms/FormField'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { TextInput } from '../../../shared/components/forms/TextInput'
import { Button } from '../../../shared/components/ui/Button'
import { flattenZodErrors, loginSchema, type LoginFormValues } from '../schemas/studentAuthSchemas'
@@ -42,14 +43,13 @@ export function StudentLoginForm({ isSubmitting, onSubmit }: StudentLoginFormPro
/>
-
setValues((current) => ({ ...current, password: event.target.value }))
}
placeholder="Enter your password"
- type="password"
value={values.password}
/>
diff --git a/src/features/student-auth/pages/StudentLoginPage.tsx b/src/features/student-auth/pages/StudentLoginPage.tsx
index b78a510..f451696 100644
--- a/src/features/student-auth/pages/StudentLoginPage.tsx
+++ b/src/features/student-auth/pages/StudentLoginPage.tsx
@@ -44,6 +44,12 @@ export function StudentLoginPage() {
}
>
+
+
+ arrow_back
+
+ Back
+
Login
{message ? (
diff --git a/src/features/student-auth/pages/StudentSignUpPage.tsx b/src/features/student-auth/pages/StudentSignUpPage.tsx
index 1f3bfc5..a469f31 100644
--- a/src/features/student-auth/pages/StudentSignUpPage.tsx
+++ b/src/features/student-auth/pages/StudentSignUpPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { authStorage } from '../../../shared/auth/authStorage'
@@ -42,6 +42,12 @@ export function StudentSignUpPage() {
title="Launch your placement profile."
>
+
+
+ arrow_back
+
+ Back
+
Student Registration
Initialize your passwordless account authorization request below.
diff --git a/src/features/student-profile/api/studentProfileEntriesApi.ts b/src/features/student-profile/api/studentProfileEntriesApi.ts
index 3e32c92..0ec20c1 100644
--- a/src/features/student-profile/api/studentProfileEntriesApi.ts
+++ b/src/features/student-profile/api/studentProfileEntriesApi.ts
@@ -6,6 +6,7 @@ import {
awardSchema,
certificateSchema,
contactLinkSchema,
+ educationSchema,
experienceSchema,
pagedResponseSchema,
} from '../schemas/profileEntrySchemas'
@@ -18,6 +19,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
PagedResponse,
@@ -69,6 +72,10 @@ export const contactLinksApi = createCollectionApi
(
+ '/me/profile/education',
+ educationSchema,
+)
export const certificatesApi = createCollectionApi(
'/me/profile/certificates',
certificateSchema,
diff --git a/src/features/student-profile/components/EducationEditor.tsx b/src/features/student-profile/components/EducationEditor.tsx
new file mode 100644
index 0000000..944ce5a
--- /dev/null
+++ b/src/features/student-profile/components/EducationEditor.tsx
@@ -0,0 +1,149 @@
+import { useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FormField } from '../../../shared/components/forms/FormField'
+import { TextInput } from '../../../shared/components/forms/TextInput'
+import { mapEducationRequest } from '../mappers/profileEntryMappers'
+import { educationFormSchema } from '../schemas/profileEntrySchemas'
+import type { Education, EducationRequest } from '../types/profileEntryTypes'
+import { ProfileEditorActions } from './ProfileEditorActions'
+
+const toMonthInput = (value: string | null) => (value ? value.slice(0, 7) : '')
+
+export function EducationEditor({
+ isPending,
+ item,
+ onCancel,
+ onSubmit,
+}: {
+ isPending: boolean
+ item?: Education
+ onCancel: () => void
+ onSubmit: (values: EducationRequest) => Promise
+}) {
+ const [values, setValues] = useState({
+ degree: item?.degree ?? '',
+ institution: item?.institution ?? '',
+ institutionUrl: item?.institutionUrl ?? '',
+ location: item?.location ?? '',
+ startDate: toMonthInput(item?.startDate ?? null),
+ endDate: toMonthInput(item?.endDate ?? null),
+ current: item?.current ?? false,
+ resultNote: item?.resultNote ?? '',
+ cvInclude: item?.cvInclude ?? true,
+ })
+ const [error, setError] = useState(null)
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setError(null)
+ const parsed = educationFormSchema.safeParse(values)
+ if (!parsed.success) {
+ setError(parsed.error.issues[0]?.message ?? 'Check the entered details.')
+ return
+ }
+ try {
+ await onSubmit(mapEducationRequest(parsed.data))
+ } catch (reason) {
+ setError(mapApiError(reason, 'protected').message)
+ }
+ }
+ return (
+
+ )
+}
diff --git a/src/features/student-profile/components/ProfileCollectionSection.tsx b/src/features/student-profile/components/ProfileCollectionSection.tsx
index 90efdd2..5d17030 100644
--- a/src/features/student-profile/components/ProfileCollectionSection.tsx
+++ b/src/features/student-profile/components/ProfileCollectionSection.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
+import { useState } from 'react'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { PaginationBar } from '../../../shared/components/data/PaginationBar'
import { SearchInput } from '../../../shared/components/data/SearchInput'
@@ -44,58 +45,86 @@ export function ProfileCollectionSection({
title: string
}) {
const mappedError = error ? mapApiError(error, 'protected') : null
+ const [isOpen, setIsOpen] = useState(true)
+ const slug = title.replaceAll(' ', '-').toLowerCase()
+ const headingId = `${slug}-title`
+ const bodyId = `${slug}-body`
return (
-
-
{title}
-
{description}
+
+
setIsOpen((current) => !current)}
+ type="button"
+ >
+
+
+
+
+
+
{title}
+
{description}
+
{addLabel}
-
{savedTitle}
-
onSearchChange(event.target.value)}
- placeholder={searchLabel}
- value={search}
- />
- {isFetching && !isPending ? (
-
- Updating results…
-
- ) : null}
- {isPending ? (
-
-
-
+ {isOpen ? (
+
+
{savedTitle}
+
onSearchChange(event.target.value)}
+ placeholder={searchLabel}
+ value={search}
+ />
+ {isFetching && !isPending ? (
+
+ Updating results…
+
+ ) : null}
+ {isPending ? (
+
+
+
+
+ ) : null}
+ {mappedError ? (
+
+ ) : null}
+ {!isPending && !mappedError ? children : null}
+ {page && page.totalPages > 0 ? (
+
+ ) : null}
) : null}
- {mappedError ? (
-
- ) : null}
- {!isPending && !mappedError ? children : null}
- {page && page.totalPages > 0 ? (
-
- ) : null}
)
}
diff --git a/src/features/student-profile/components/ProfileIdentityCard.tsx b/src/features/student-profile/components/ProfileIdentityCard.tsx
index 838f542..29fefec 100644
--- a/src/features/student-profile/components/ProfileIdentityCard.tsx
+++ b/src/features/student-profile/components/ProfileIdentityCard.tsx
@@ -39,7 +39,7 @@ export function ProfileIdentityCard({
Level {profile.studentLevel}
-
Cohort / Batch
+ Batch
{profile.cohortYear ?? 'Not available'}
diff --git a/src/features/student-profile/components/ProfileSections.tsx b/src/features/student-profile/components/ProfileSections.tsx
index 696e1c0..5cbfba7 100644
--- a/src/features/student-profile/components/ProfileSections.tsx
+++ b/src/features/student-profile/components/ProfileSections.tsx
@@ -15,6 +15,8 @@ import {
useCertificateMutations,
useContactLinkMutations,
useContactLinks,
+ useEducation,
+ useEducationMutations,
useExperience,
useExperienceMutations,
} from '../hooks/useProfileEntries'
@@ -28,6 +30,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
ProfileCollectionQuery,
@@ -38,6 +42,7 @@ import { ActivityEditor } from './ActivityEditor'
import { AwardEditor } from './AwardEditor'
import { CertificateEditor } from './CertificateEditor'
import { ContactLinkEditor } from './ContactLinkEditor'
+import { EducationEditor } from './EducationEditor'
import { ExperienceEditor } from './ExperienceEditor'
import { ProfileCollectionEmpty, ProfileCollectionSection } from './ProfileCollectionSection'
import { ProfileEntryCard } from './ProfileEntryCard'
@@ -244,6 +249,108 @@ export function ProfessionalLinksSection() {
)
}
+export function EducationSection() {
+ const state = useProfileSectionState('startDate,desc')
+ const query = useEducation(state.query)
+ const mutations = useEducationMutations()
+ const { notify } = useNotifications()
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+ const pending =
+ mutations.create.isPending || mutations.update.isPending || mutations.remove.isPending
+ const save = async (values: EducationRequest) => {
+ const item =
+ editing === 'new'
+ ? await mutations.create.mutateAsync(values)
+ : await mutations.update.mutateAsync({ id: editing!.id, version: editing!.version, values })
+ notify({
+ tone: 'success',
+ title: editing === 'new' ? 'Education added' : 'Education updated',
+ message: `${item.degree} was saved.`,
+ })
+ setEditing(null)
+ }
+ const remove = async () => {
+ if (!deleting) return
+ try {
+ await mutations.remove.mutateAsync({ id: deleting.id, version: deleting.version })
+ afterDelete(query.data?.items ?? [], state.page, state.setPage)
+ setDeleting(null)
+ notify({ tone: 'success', title: 'Education deleted', message: 'The entry was removed.' })
+ } catch (error) {
+ notifyFailure(notify, error, 'Unable to delete Education entry')
+ }
+ }
+ const items = query.data?.items ?? []
+ return (
+ <>
+ setEditing('new')}
+ onPageChange={state.setPage}
+ onRetry={() => void query.refetch()}
+ onSearchChange={state.setSearch}
+ page={query.data?.page}
+ savedTitle="Saved Education"
+ search={state.search}
+ searchLabel="Search education entries"
+ title="Education"
+ >
+ {items.length === 0 ? (
+ setEditing('new')} search={state.search} title="Education" />
+ ) : (
+
+ {items.map((item) => (
+
setDeleting(item)}
+ onEdit={() => setEditing(item)}
+ />
+ }
+ cvInclude={item.cvInclude}
+ key={item.id}
+ subtitle={`${item.institution}${item.location ? ` · ${item.location}` : ''}${item.startDate ? ` · ${item.startDate} – ${item.current ? 'Present' : item.endDate ?? ''}` : ''}`}
+ title={item.degree}
+ >
+ {item.resultNote ? {item.resultNote}
: null}
+
+ ))}
+
+ )}
+
+ {editing ? (
+ setEditing(null)}
+ title={editing === 'new' ? 'Add Education' : 'Edit Education'}
+ >
+ setEditing(null)}
+ onSubmit={save}
+ />
+
+ ) : null}
+ {deleting ? (
+ setDeleting(null)}
+ onConfirm={() => void remove()}
+ />
+ ) : null}
+ >
+ )
+}
+
export function CertificatesSection({ evidencePolicy }: { evidencePolicy?: FileUploadConstraint }) {
const state = useProfileSectionState('issueDate,desc')
const query = useCertificates(state.query)
diff --git a/src/features/student-profile/hooks/useProfileEntries.ts b/src/features/student-profile/hooks/useProfileEntries.ts
index 08395d4..abe0f77 100644
--- a/src/features/student-profile/hooks/useProfileEntries.ts
+++ b/src/features/student-profile/hooks/useProfileEntries.ts
@@ -4,6 +4,7 @@ import {
awardsApi,
certificatesApi,
contactLinksApi,
+ educationApi,
experienceApi,
} from '../api/studentProfileEntriesApi'
import type {
@@ -15,6 +16,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
PagedResponse,
@@ -68,6 +71,8 @@ function useCollectionMutations(
export const useContactLinks = (query: ProfileCollectionQuery) =>
useCollection('contact-links', contactLinksApi, query)
+export const useEducation = (query: ProfileCollectionQuery) =>
+ useCollection('education', educationApi, query)
export const useCertificates = (query: ProfileCollectionQuery) =>
useCollection('certificates', certificatesApi, query)
export const useAwards = (query: ProfileCollectionQuery) =>
@@ -78,6 +83,8 @@ export const useExperience = (query: ProfileCollectionQuery) =>
useCollection('experience', experienceApi, query)
export const useContactLinkMutations = () =>
useCollectionMutations('contact-links', contactLinksApi)
+export const useEducationMutations = () =>
+ useCollectionMutations('education', educationApi)
export const useCertificateMutations = () =>
useCollectionMutations('certificates', certificatesApi)
export const useAwardMutations = () =>
diff --git a/src/features/student-profile/mappers/profileEntryMappers.ts b/src/features/student-profile/mappers/profileEntryMappers.ts
index 7113db8..b37b642 100644
--- a/src/features/student-profile/mappers/profileEntryMappers.ts
+++ b/src/features/student-profile/mappers/profileEntryMappers.ts
@@ -7,11 +7,26 @@ import type {
CertificateRequest,
ContactLinkFormValues,
ContactLinkRequest,
+ EducationFormValues,
+ EducationRequest,
ExperienceFormValues,
ExperienceRequest,
} from '../types/profileEntryTypes'
const nullable = (value: string) => value.trim() || null
+const nullableMonth = (value: string) => (value.trim() ? `${value.trim()}-01` : null)
+
+export const mapEducationRequest = (value: EducationFormValues): EducationRequest => ({
+ degree: value.degree.trim(),
+ institution: value.institution.trim(),
+ institutionUrl: nullable(value.institutionUrl),
+ location: nullable(value.location),
+ startDate: nullableMonth(value.startDate),
+ endDate: value.current ? null : nullableMonth(value.endDate),
+ current: value.current,
+ resultNote: nullable(value.resultNote),
+ cvInclude: value.cvInclude,
+})
export const mapContactLinkRequest = (value: ContactLinkFormValues): ContactLinkRequest => ({
label: value.label.trim(),
diff --git a/src/features/student-profile/pages/StudentProfilePage.tsx b/src/features/student-profile/pages/StudentProfilePage.tsx
index bc52939..1ba461b 100644
--- a/src/features/student-profile/pages/StudentProfilePage.tsx
+++ b/src/features/student-profile/pages/StudentProfilePage.tsx
@@ -8,6 +8,7 @@ import {
ActivitiesSection,
AwardsSection,
CertificatesSection,
+ EducationSection,
ExperienceSection,
ProfessionalLinksSection,
} from '../components/ProfileSections'
@@ -73,6 +74,7 @@ export function StudentProfilePage() {
) : null}
+
diff --git a/src/features/student-profile/schemas/profileEntrySchemas.ts b/src/features/student-profile/schemas/profileEntrySchemas.ts
index 2735442..cf5cfe4 100644
--- a/src/features/student-profile/schemas/profileEntrySchemas.ts
+++ b/src/features/student-profile/schemas/profileEntrySchemas.ts
@@ -31,6 +31,19 @@ export const contactLinkSchema = z
displayOrder: z.number().int().nonnegative(),
})
.strict()
+export const educationSchema = z
+ .object({
+ ...baseResponse,
+ degree: z.string().min(1).max(200),
+ institution: z.string().min(1).max(200),
+ institutionUrl: safeWebUrlSchema.nullable(),
+ location: z.string().nullable(),
+ startDate: nullableDateSchema,
+ endDate: nullableDateSchema,
+ current: z.boolean(),
+ resultNote: z.string().nullable(),
+ })
+ .strict()
export const certificateSchema = z
.object({
...baseResponse,
@@ -91,6 +104,33 @@ export const contactLinkFormSchema = z.object({
displayOrder: z.string().regex(/^\d+$/, 'Display Order must be zero or greater.'),
cvInclude: z.boolean(),
})
+const monthOnlySchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use the MM/YYYY format.')
+export const educationFormSchema = z
+ .object({
+ degree: z.string().trim().min(1, 'Degree / Field of Study is required.').max(200),
+ institution: z.string().trim().min(1, 'School / Institution is required.').max(200),
+ institutionUrl: optionalSafeUrl,
+ location: nullableText,
+ startDate: z.union([z.literal(''), monthOnlySchema]),
+ endDate: z.union([z.literal(''), monthOnlySchema]),
+ current: z.boolean(),
+ resultNote: z.string().trim().max(500),
+ cvInclude: z.boolean(),
+ })
+ .superRefine((value, context) => {
+ if (value.current && value.endDate)
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'Currently studying entries cannot have an End Date.',
+ })
+ if (value.startDate && value.endDate && value.endDate < value.startDate)
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'End Date cannot be before Start Date.',
+ })
+ })
export const certificateFormSchema = z.object({
title: z.string().trim().min(1, 'Title is required.').max(200),
issuer: z.string().trim().min(1, 'Issuer is required.').max(200),
diff --git a/src/features/student-profile/types/profileEntryTypes.ts b/src/features/student-profile/types/profileEntryTypes.ts
index f4563ec..afad169 100644
--- a/src/features/student-profile/types/profileEntryTypes.ts
+++ b/src/features/student-profile/types/profileEntryTypes.ts
@@ -3,7 +3,7 @@ import type { FileAsset } from './profileFileTypes'
export const PROFILE_SECTION_PAGE_SIZE = 5
export type ProfileCollectionKind =
- 'contact-links' | 'certificates' | 'awards' | 'activities' | 'experience'
+ 'contact-links' | 'education' | 'certificates' | 'awards' | 'activities' | 'experience'
export type ProfileCollectionQuery = {
page: number
@@ -35,6 +35,16 @@ export type ContactLink = VersionedProfileEntry & {
url: string
displayOrder: number
}
+export type Education = VersionedProfileEntry & {
+ degree: string
+ institution: string
+ institutionUrl: string | null
+ location: string | null
+ startDate: string | null
+ endDate: string | null
+ current: boolean
+ resultNote: string | null
+}
export type Certificate = VersionedProfileEntry & {
title: string
issuer: string
@@ -71,6 +81,17 @@ export type ContactLinkFormValues = {
displayOrder: string
cvInclude: boolean
}
+export type EducationFormValues = {
+ degree: string
+ institution: string
+ institutionUrl: string
+ location: string
+ startDate: string
+ endDate: string
+ current: boolean
+ resultNote: string
+ cvInclude: boolean
+}
export type CertificateFormValues = {
title: string
issuer: string
@@ -110,6 +131,17 @@ export type ContactLinkRequest = {
displayOrder: number
cvInclude: boolean
}
+export type EducationRequest = {
+ degree: string
+ institution: string
+ institutionUrl: string | null
+ location: string | null
+ startDate: string | null
+ endDate: string | null
+ current: boolean
+ resultNote: string | null
+ cvInclude: boolean
+}
export type CertificateRequest = {
title: string
issuer: string
diff --git a/src/index.css b/src/index.css
index 087ebc4..a7c6ae2 100644
--- a/src/index.css
+++ b/src/index.css
@@ -295,6 +295,29 @@ textarea:focus-visible {
padding: 24px 0;
}
+.app-shell-standalone:has(.gateway-v2-page),
+.app-shell-standalone:has(.gateway-v2-intro-cinema) {
+ width: 100vw;
+ max-width: 100vw;
+ min-height: 100dvh;
+ height: 100dvh;
+ padding: 0;
+ margin: 0;
+ background: #020617;
+ overflow: hidden;
+}
+
+.app-main-standalone:has(.gateway-v2-page),
+.app-main-standalone:has(.gateway-v2-intro-cinema) {
+ width: 100vw;
+ max-width: 100vw;
+ min-height: 100dvh;
+ height: 100dvh;
+ padding: 0;
+ margin: 0;
+ display: block;
+}
+
.page-transition {
width: 100%;
animation: pageTransitionIn var(--motion-duration-medium) var(--motion-standard) both;
@@ -306,7 +329,8 @@ textarea:focus-visible {
}
.page-transition:has(.gateway-page),
-.page-transition:has(.gateway-v2-page) {
+.page-transition:has(.gateway-v2-page),
+.page-transition:has(.gateway-v2-intro-cinema) {
animation: none !important;
transform: none !important;
}
@@ -691,6 +715,25 @@ textarea:focus-visible {
padding: 0;
}
+.auth-back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ justify-self: start;
+ font-size: 0.94rem;
+ font-weight: 500;
+ color: var(--color-text-muted, var(--color-text));
+ text-decoration: none;
+}
+
+.auth-back-link:hover {
+ color: var(--primary);
+}
+
+.auth-back-link .material-symbols-outlined {
+ font-size: 20px;
+}
+
.auth-form-card h1,
.auth-centered-card h1 {
margin: 0;
@@ -1421,12 +1464,55 @@ body.admin-mobile-drawer-open {
gap: 16px;
}
+.profile-section-heading-main {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ min-width: 0;
+ flex: 1;
+}
+
.profile-section-heading h2,
.profile-section-heading p,
.profile-form-alert p {
margin: 0;
}
+.profile-section-toggle {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ margin-top: 2px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-card);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.profile-section-toggle:hover {
+ background: var(--surface-container-high);
+ color: var(--color-text);
+ border-color: var(--color-text-muted);
+}
+
+.profile-section-toggle svg {
+ transition: transform 0.2s ease;
+}
+
+.profile-section-toggle[aria-expanded='false'] svg {
+ transform: rotate(-90deg);
+}
+
+.profile-section-body {
+ display: grid;
+ gap: 18px;
+}
+
.profile-unsaved-indicator {
flex: none;
padding: 6px 10px;
@@ -1518,6 +1604,7 @@ body.admin-mobile-drawer-open {
.registered-students-controls,
.registered-students-toolbar-heading {
display: grid;
+ min-width: 0;
gap: 18px;
}
@@ -1541,6 +1628,7 @@ body.admin-mobile-drawer-open {
.registered-students-levels,
.pagination-size-control {
display: grid;
+ min-width: 0;
gap: 8px;
}
@@ -2087,6 +2175,47 @@ p {
outline: none;
}
+.password-input-wrap {
+ position: relative;
+}
+
+.password-input-wrap .input {
+ padding-right: 44px;
+}
+
+.password-toggle-button {
+ position: absolute;
+ top: 50%;
+ right: 4px;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background-color var(--motion-duration-fast) var(--motion-standard),
+ color var(--motion-duration-fast) var(--motion-standard);
+}
+
+.password-toggle-button:hover {
+ background: var(--surface-container-high);
+ color: var(--color-text);
+}
+
+.password-toggle-button:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 2px;
+}
+
+.password-toggle-button .material-symbols-outlined {
+ font-size: 20px;
+}
+
.error-text {
color: var(--color-danger);
font-size: 0.9rem;
@@ -2125,6 +2254,15 @@ p {
line-height: 1.5;
}
+.inline-alert-success {
+ border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
+ border-radius: var(--radius-md);
+ background: var(--success-bg);
+ color: var(--color-text);
+ padding: 12px 14px;
+ line-height: 1.5;
+}
+
.auth-test-credentials {
display: grid;
gap: 4px;
@@ -2208,6 +2346,38 @@ p {
overflow-x: auto;
}
+.eligible-students-import-panel {
+ display: grid;
+ gap: 14px;
+}
+
+.eligible-students-import-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+}
+
+.eligible-students-import-errors {
+ margin: 8px 0 0;
+ padding-left: 20px;
+}
+
+.eligible-students-import-errors li {
+ font-size: 0.9rem;
+}
+
+.eligible-students-list-card {
+ display: grid;
+ gap: 16px;
+}
+
+.eligible-students-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
table {
width: 100%;
border-collapse: collapse;
@@ -4941,6 +5111,21 @@ body.dark-mode .s5-records-page {
background: var(--color-card-muted);
}
+.s5-cv-always-included-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 4px 0;
+}
+
+.s5-cv-always-included-list span {
+ padding: 4px 10px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-pill, 999px);
+ background: var(--color-card);
+ font-size: 0.85rem;
+}
+
.s5-cv-source-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
diff --git a/src/mocks/handlers/studentHandlers.ts b/src/mocks/handlers/studentHandlers.ts
index f82ce03..e2530a5 100644
--- a/src/mocks/handlers/studentHandlers.ts
+++ b/src/mocks/handlers/studentHandlers.ts
@@ -8,6 +8,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
VersionedProfileEntry,
@@ -53,6 +55,7 @@ function baseEntry(id = nextId(), version = 1) {
type MockProfileState = {
profile: StudentProfileResponseDto
contactLinks: ContactLink[]
+ education: Education[]
certificates: Certificate[]
awards: Award[]
activities: Activity[]
@@ -106,6 +109,20 @@ function createInitialState(): MockProfileState {
cvInclude: false,
},
] satisfies ContactLink[],
+ education: [
+ {
+ ...baseEntry('15000000-0000-4000-8000-000000000001'),
+ degree: 'Bachelor of Computer Science',
+ institution: 'University of Ruhuna',
+ institutionUrl: 'https://ruh.ac.lk',
+ location: 'Sri Lanka',
+ startDate: '2022-01-01',
+ endDate: null,
+ current: true,
+ resultNote: 'Current GPA - 3.74 / 4.00',
+ cvInclude: true,
+ },
+ ] satisfies Education[],
certificates: [
{
...baseEntry('20000000-0000-4000-8000-000000000001'),
@@ -310,6 +327,28 @@ const contactHandlers = collectionHandlers<
cvInclude: body.cvInclude ?? previous?.cvInclude ?? true,
}),
})
+const educationHandlers = collectionHandlers<
+ Education,
+ EducationRequest & Record
+>({
+ path: `${apiBase}/me/profile/education`,
+ get: () => state.education,
+ set: (items) => {
+ state.education = items
+ },
+ searchable: (item) => `${item.degree} ${item.institution}`,
+ build: (body, previous) => ({
+ degree: body.degree ?? previous!.degree,
+ institution: body.institution ?? previous!.institution,
+ institutionUrl: body.institutionUrl ?? null,
+ location: body.location ?? null,
+ startDate: body.startDate ?? null,
+ endDate: body.current ? null : (body.endDate ?? null),
+ current: body.current ?? previous?.current ?? false,
+ resultNote: body.resultNote ?? null,
+ cvInclude: body.cvInclude ?? previous?.cvInclude ?? true,
+ }),
+})
const certificateHandlers = collectionHandlers<
Certificate,
CertificateRequest & Record
@@ -467,6 +506,7 @@ export const studentHandlers = [
return HttpResponse.json(state.profile)
}),
...contactHandlers,
+ ...educationHandlers,
...certificateHandlers,
http.put(`${apiBase}/me/profile/certificates/:id/evidence`, async ({ params, request }) => {
const index = state.certificates.findIndex((item) => item.id === params.id)
diff --git a/src/shared/components/forms/PasswordInput.tsx b/src/shared/components/forms/PasswordInput.tsx
new file mode 100644
index 0000000..42ea1bb
--- /dev/null
+++ b/src/shared/components/forms/PasswordInput.tsx
@@ -0,0 +1,29 @@
+import { forwardRef, useId, useState } from 'react'
+import { TextInput, type TextInputProps } from './TextInput'
+
+export const PasswordInput = forwardRef(function PasswordInput(
+ { id, ...props },
+ ref,
+) {
+ const [visible, setVisible] = useState(false)
+ const generatedId = useId()
+ const inputId = id ?? generatedId
+
+ return (
+
+
+ setVisible((current) => !current)}
+ type="button"
+ >
+
+ {visible ? 'visibility_off' : 'visibility'}
+
+
+
+ )
+})
diff --git a/src/shared/components/overlays/LogoutConfirmDialog.tsx b/src/shared/components/overlays/LogoutConfirmDialog.tsx
new file mode 100644
index 0000000..db945bd
--- /dev/null
+++ b/src/shared/components/overlays/LogoutConfirmDialog.tsx
@@ -0,0 +1,36 @@
+import { useState } from 'react'
+import { Button } from '../ui/Button'
+import { ConfirmDialog } from './ConfirmDialog'
+
+export function LogoutConfirmDialog({
+ onClose,
+ onConfirm,
+}: {
+ onClose: () => void
+ onConfirm: () => Promise
+}) {
+ const [isPending, setIsPending] = useState(false)
+
+ const confirm = async () => {
+ setIsPending(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+ Are you sure you want to log out?
+
+
+ Cancel
+
+ void confirm()}>
+ Log Out
+
+
+
+ )
+}
diff --git a/src/shared/skeletons/GatewaySkeleton.tsx b/src/shared/skeletons/GatewaySkeleton.tsx
index db21ae7..1ce38cd 100644
--- a/src/shared/skeletons/GatewaySkeleton.tsx
+++ b/src/shared/skeletons/GatewaySkeleton.tsx
@@ -3,10 +3,7 @@ import { SkeletonStatusRegion } from './SkeletonPrimitives'
export function GatewaySkeleton() {
return (
-
-
-
-
+
)
}
diff --git a/src/styles/skeleton-system.css b/src/styles/skeleton-system.css
index f9f16d3..0f6cd38 100644
--- a/src/styles/skeleton-system.css
+++ b/src/styles/skeleton-system.css
@@ -99,11 +99,13 @@
.loading-boundary {
display: grid;
+ min-width: 0;
}
.loading-boundary__skeleton,
.loading-boundary__content {
grid-area: 1 / 1;
+ min-width: 0;
}
.loading-boundary__skeleton {
diff --git a/src/styles/sprint78-wireframe-alignment.css b/src/styles/sprint78-wireframe-alignment.css
index 668a2d6..eeda023 100644
--- a/src/styles/sprint78-wireframe-alignment.css
+++ b/src/styles/sprint78-wireframe-alignment.css
@@ -1639,6 +1639,7 @@
}
.registered-students-page .registered-students-controls {
+ min-width: 0;
grid-template-columns: minmax(280px, 1.35fr) minmax(220px, 0.65fr);
}
diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts
index 0a6b71b..9a78e8d 100644
--- a/src/test/setupTests.ts
+++ b/src/test/setupTests.ts
@@ -12,6 +12,12 @@ import { resetSprint78Mocks } from '../mocks/handlers/sprint78Handlers'
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
+ if (typeof window !== 'undefined' && window.HTMLMediaElement) {
+ window.HTMLMediaElement.prototype.play = async () => undefined
+ window.HTMLMediaElement.prototype.pause = () => {}
+ window.HTMLMediaElement.prototype.load = () => {}
+ }
+
// MSW/Undici strict AbortSignal checking throws TypeError when receiving JSDOM's AbortSignal.
// We strip the signal from the test fetch wrapper AFTER MSW has patched fetch.
// This makes our wrapper the outermost layer, allowing MSW interception to succeed,