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 ( - - + + {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.

+ + {deleting ? ( + setDeleting(null)} + title="Remove upload" + > +

+ Remove {deleting.originalFilename}? This cannot be undone. +

+ {deleteUpload.isError ? ( +

+ {mapApiError(deleteUpload.error, 'protected').message} +

+ ) : null} +
+ + +
+
+ ) : 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() { } >
+ + + 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 ( + +
+ {formError ? ( +
+ {formError} +
+ ) : null} + + + update('indexNumber', event.target.value)} + placeholder="e.g., CS/2022/00123" + ref={indexRef} + value={values.indexNumber} + /> + + + + update('universityEmail', event.target.value)} + placeholder="e.g., student@dcs.ruh.ac.lk" + type="email" + value={values.universityEmail} + /> + + + + update('fullName', event.target.value)} + placeholder="e.g., Nimal Perera" + value={values.fullName} + /> + + + + + + +
+ + +
+
+
+ ) +} 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} + /> + +
+ {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 ( +
+ + + + + + + + + + + + + + {items.map((student) => ( + + + + + + + + + ))} + +
Eligible student roster
Index NumberFull NameUniversity EmailLevelStatusActions
{student.indexNumber}{student.fullName}{student.universityEmail}{student.academicLevel} + + {student.registered ? 'Registered' : 'Not registered'} + + + + +
+
+ ) +} 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

+ +
+ 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. +

+
+ + +
+
+ ) : 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 (
- - -
) 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() {