Skip to content
Merged
88 changes: 88 additions & 0 deletions e2e/smoke/landing.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
4 changes: 4 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 52 additions & 31 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
candidate: "🎓",
staff: "📋",
company: "🏭",
admin: "⚙️",
inspector: "🔍",
};
Comment on lines +9 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Strengthen typing to align with portalRoles and satisfy strict mode.

portalIcons is typed as Record<string, string>, but the keys are exactly the five roles defined in portalRoles (line 36). With noUncheckedIndexedAccess enabled (per coding guidelines), accessing portalIcons[role] at line 108 will return string | undefined, requiring a runtime check or type assertion.

Define a more precise type to ensure compile-time safety and eliminate the need for runtime guards.

🔒 Proposed fix to align types with portalRoles
+type PortalRole = typeof portalRoles[number];
+
-const portalIcons: Record<string, string> = {
+const portalIcons: Record<PortalRole, string> = {
   candidate: "🎓",
   staff: "📋",
   company: "🏭",
   admin: "⚙️",
   inspector: "🔍",
 };

Alternatively, use satisfies for inference while retaining safety:

-const portalIcons: Record<string, string> = {
+const portalIcons = {
   candidate: "🎓",
   staff: "📋",
   company: "🏭",
   admin: "⚙️",
   inspector: "🔍",
-};
+} satisfies Record<PortalRole, string>;

As per coding guidelines: "Strict mode enabled — no implicit any, no unchecked index access."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const portalIcons: Record<string, string> = {
candidate: "🎓",
staff: "📋",
company: "🏭",
admin: "⚙️",
inspector: "🔍",
};
type PortalRole = typeof portalRoles[number];
const portalIcons: Record<PortalRole, string> = {
candidate: "🎓",
staff: "📋",
company: "🏭",
admin: "⚙️",
inspector: "🔍",
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 9 - 15, The portalIcons map is currently typed
as Record<string, string>, causing unchecked indexed access against the specific
portalRoles; update types so keys are the exact union of roles and eliminate
undefined on portalIcons[role]. Derive a PortalRole from the existing
portalRoles (e.g., using typeof portalRoles[number] or make portalRoles readonly
with as const) and then type portalIcons as Record<PortalRole, string> or
recreate portalIcons using the satisfies pattern so accesses like
portalIcons[role] are typed as string (refer to symbols portalRoles and
portalIcons and the place where portalIcons[role] is used).


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;
Expand Down Expand Up @@ -42,11 +62,11 @@ export default async function Home() {
<div className="landingOpsMain">
<div className="landingOpsSearch">
<span>Candidate search</span>
<strong>jaafar</strong>
<small>80 scoped results · FAD · needs review · Lebanon</small>
<strong>find talent</strong>
<small>80 results · filtered · ready for review</small>
</div>
<div className="landingOpsColumns">
{["Profile ready", "CV export", "Timesheet", "Payment"].map((item, index) => (
{["Profile", "CV export", "Timesheet", "Payment"].map((item, index) => (
<div key={item}>
<span>{item}</span>
<strong>{index === 1 ? "PDF" : "Live"}</strong>
Expand All @@ -55,27 +75,27 @@ export default async function Home() {
</div>
</div>
<div className="landingOpsAside">
<span>Command</span>
<strong>Send CVs to employer</strong>
<small>Same action layer for staff and admin, scoped by role.</small>
<span>Actions</span>
<strong>Send CVs</strong>
<small>One click to share with employers.</small>
</div>
</div>
</div>
<div className="landingHeroCopy">
<p className="eyebrow">Next-generation StudentHub</p>
<h1>One modern platform, purpose-built portals.</h1>
<p className="eyebrow">The StudentHub platform</p>
<h1>Every role gets its own workspace.</h1>
<p>
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.
</p>
<div className="landingActions">
<Link className="primary" href="/login/candidate">Students start here</Link>
<Link href="/login">Choose another portal</Link>
<Link className="primary" href="/login">Get started</Link>
<Link href="/login">Explore portals</Link>
</div>
<div className="landingHeroStats" aria-label="StudentHub platform goals">
<span>Role-specific access</span>
<span>Shared search and documents</span>
<span>Production-data migration path</span>
<div className="landingHeroStats" aria-label="Platform highlights">
<span>5 role-specific portals</span>
<span>Unified search &amp; documents</span>
<span>End-to-end workflows</span>
</div>
</div>
</section>
Expand All @@ -85,6 +105,7 @@ export default async function Home() {
const portal = portalContent[role];
return (
<Link href={portal.href as Route} key={role}>
<span className="portalIcon" aria-hidden="true">{portalIcons[role]}</span>
<span>{portal.label}</span>
<strong>{portal.audience}</strong>
<small>{portal.promise}</small>
Expand All @@ -93,20 +114,20 @@ export default async function Home() {
})}
</section>

<section className="landingSearchSection">
<section className="landingBenefitsSection">
<div>
<p className="eyebrow">Search-first migration</p>
<h2>Candidate search should feel instant, forgiving, and operational.</h2>
<p className="eyebrow">Why StudentHub</p>
<h2>Built for how staffing actually works.</h2>
<p>
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.
</p>
</div>
<div className="searchSignalGrid">
{searchSignals.map((signal) => (
<article key={signal}>
<span>Search</span>
<strong>{signal}</strong>
<div className="benefitGrid">
{benefits.map((b) => (
<article key={b.title}>
<strong>{b.title}</strong>
<p>{b.body}</p>
</article>
))}
</div>
Expand Down
2 changes: 0 additions & 2 deletions src/modules/candidates/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
Loading