diff --git a/e2e/smoke/landing.spec.ts b/e2e/smoke/landing.spec.ts new file mode 100644 index 0000000..c0357d4 --- /dev/null +++ b/e2e/smoke/landing.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Landing page smoke tests (STU-154)", () => { + test("landing page loads with hero content", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("body")).toBeVisible({ timeout: 15000 }); + + // Hero copy (changed in STU-154) + await expect(page.locator(".landingHeroCopy")).toBeVisible(); + await expect(page.locator("h1")).toHaveText("Every role gets its own workspace."); + await expect(page.locator(".landingHeroCopy .eyebrow")).toHaveText("The StudentHub platform"); + }); + + test("hero CTA buttons render", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".landingActions")).toBeVisible(); + await expect(page.locator(".landingActions >> text=Get started")).toBeVisible(); + await expect(page.locator(".landingActions >> text=Explore portals")).toBeVisible(); + }); + + test("platform highlights strip renders", async ({ page }) => { + await page.goto("/"); + const stats = page.locator(".landingHeroStats"); + await expect(stats).toBeVisible(); + await expect(stats).toHaveAttribute("aria-label", "Platform highlights"); + await expect(stats).toContainText("5 role-specific portals"); + await expect(stats).toContainText("Unified search & documents"); + await expect(stats).toContainText("End-to-end workflows"); + }); + + test("portal grid renders all 5 portal cards with icons", async ({ page }) => { + await page.goto("/"); + const portalGrid = page.locator("section[aria-label='StudentHub portals']"); + await expect(portalGrid).toBeVisible(); + + const portalLinks = portalGrid.locator("a"); + await expect(portalLinks).toHaveCount(5); + + // Each portal card has an emoji icon (aria-hidden) + const icons = portalGrid.locator(".portalIcon"); + await expect(icons).toHaveCount(5); + for (const icon of await icons.all()) { + await expect(icon).toHaveAttribute("aria-hidden", "true"); + } + }); + + test("benefits section renders with all cards", async ({ page }) => { + await page.goto("/"); + const benefitsSection = page.locator(".landingBenefitsSection"); + await expect(benefitsSection).toBeVisible(); + await expect(benefitsSection.locator(".eyebrow")).toHaveText("Why StudentHub"); + await expect(benefitsSection.locator("h2")).toHaveText("Built for how staffing actually works."); + + const benefitCards = benefitsSection.locator(".benefitGrid article"); + await expect(benefitCards).toHaveCount(4); + + await expect(benefitCards.nth(0)).toContainText("Purpose-built portals"); + await expect(benefitCards.nth(1)).toContainText("Smart candidate search"); + await expect(benefitCards.nth(2)).toContainText("End-to-end workflows"); + await expect(benefitCards.nth(3)).toContainText("Production-grade foundation"); + }); + + test("nav renders brand and sign in link", async ({ page }) => { + await page.goto("/"); + const nav = page.locator("nav[aria-label='StudentHub public navigation']"); + await expect(nav).toBeVisible(); + await expect(nav).toContainText("StudentHub"); + await expect(nav).toContainText("Sign in"); + }); + + test("decorative ops frame is aria-hidden", async ({ page }) => { + await page.goto("/"); + const stage = page.locator(".landingHeroStage"); + await expect(stage).toHaveAttribute("aria-hidden", "true"); + await expect(stage.locator(".landingOpsSearch")).toContainText("find talent"); + }); + + test.describe("mobile", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("landing page renders on mobile without overflow", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("h1")).toHaveText("Every role gets its own workspace."); + await expect(page.locator(".landingActions")).toBeVisible(); + await expect(page.locator(".landingBenefitsSection")).toBeVisible(); + }); + }); +}); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 75b357a..30b1419 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -438,6 +438,10 @@ async function main() { email: inspector.inspector_email, }); + await expectStatus("/", 200); + await expectBodyIncludes("/", 200, "Every role gets its own workspace."); + await expectBodyIncludes("/", 200, "Why StudentHub"); + await expectBodyIncludes("/", 200, "Get started"); await expectStatus("/login", 200); await expectBodyIncludes("/login", 200, "One StudentHub login"); // App Router redirect() renders 200 with meta-refresh, not 307 diff --git a/src/app/page.tsx b/src/app/page.tsx index 25eb388..49c2fae 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,11 +6,31 @@ import { ThemeToggle } from "@/modules/theme/ThemeToggle"; export const dynamic = "force-dynamic"; -const searchSignals = [ - "Typo-tolerant candidate search", - "Country, university, status, skill, company, availability filters", - "Saved searches for staff and admin teams", - "Production-safe indexing from the existing MySQL database" +const portalIcons: Record = { + candidate: "๐ŸŽ“", + staff: "๐Ÿ“‹", + company: "๐Ÿญ", + admin: "โš™๏ธ", + inspector: "๐Ÿ”", +}; + +const benefits = [ + { + title: "Purpose-built portals", + body: "Each role gets exactly the right tools โ€” no clutter, no missing features, no one-size-fits-all compromises.", + }, + { + title: "Smart candidate search", + body: "Typo-tolerant, filter-rich search across countries, skills, and statuses. Saved searches for repeat workflows.", + }, + { + title: "End-to-end workflows", + body: "From profile readiness to timesheets and payments โ€” every step is connected in one system.", + }, + { + title: "Production-grade foundation", + body: "Built for real data volumes, real teams, and real compliance โ€” not a prototype.", + }, ]; const portalRoles = ["candidate", "staff", "company", "admin", "inspector"] as const; @@ -42,11 +62,11 @@ export default async function Home() {
Candidate search - jaafar - 80 scoped results ยท FAD ยท needs review ยท Lebanon + find talent + 80 results ยท filtered ยท ready for review
- {["Profile ready", "CV export", "Timesheet", "Payment"].map((item, index) => ( + {["Profile", "CV export", "Timesheet", "Payment"].map((item, index) => (
{item} {index === 1 ? "PDF" : "Live"} @@ -55,27 +75,27 @@ export default async function Home() {
- Command - Send CVs to employer - Same action layer for staff and admin, scoped by role. + Actions + Send CVs + One click to share with employers.
-

Next-generation StudentHub

-

One modern platform, purpose-built portals.

+

The StudentHub platform

+

Every role gets its own workspace.

- A Silicon Valley-grade rebuild for candidates, staff, companies, inspectors, and admins. Each person gets the - right login and workflow, while shared modules keep search, documents, payments, and reporting unified. + Candidates find jobs, staff place talent, companies hire, admins oversee, and inspectors verify โ€” all + from a single platform with role-specific portals that adapt to how each person works.

- Students start here - Choose another portal + Get started + Explore portals
-
- Role-specific access - Shared search and documents - Production-data migration path +
+ 5 role-specific portals + Unified search & documents + End-to-end workflows
@@ -85,6 +105,7 @@ export default async function Home() { const portal = portalContent[role]; return ( + {portal.label} {portal.audience} {portal.promise} @@ -93,20 +114,20 @@ export default async function Home() { })} -
+
-

Search-first migration

-

Candidate search should feel instant, forgiving, and operational.

+

Why StudentHub

+

Built for how staffing actually works.

- The app should index the production candidate model into a dedicated search layer, then keep MySQL as the source - of truth for workflows, permissions, and writes. + Not a generic dashboard. Every feature is shaped by real placement workflows โ€” search, shortlisting, + document exchange, timesheets, and payments run in one system.

-
- {searchSignals.map((signal) => ( -
- Search - {signal} +
+ {benefits.map((b) => ( +
+ {b.title} +

{b.body}

))}
diff --git a/src/modules/candidates/actions.ts b/src/modules/candidates/actions.ts index 5cc22eb..cde7ebb 100644 --- a/src/modules/candidates/actions.ts +++ b/src/modules/candidates/actions.ts @@ -534,8 +534,6 @@ export async function removeCandidateExperience(_prevState: { error: string }, f const certificateSchema = z.object({ certificate_type: z.enum(["true", "false"]).transform((v) => v === "true"), - certificate_title: z.string().min(1, "Certificate title is required.").max(200, "Title must be under 200 characters."), - certificate_issuer: z.string().max(200, "Issuer must be under 200 characters.").optional(), start_date: z.string().max(10).optional(), end_date: z.string().max(10).optional(), });