From 3a69fc7455ae9c871a82f700a9d50426c5a108d5 Mon Sep 17 00:00:00 2001 From: drewsephski Date: Wed, 30 Sep 2026 14:59:15 -0500 Subject: [PATCH] feat(ui): consolidate JobSync design system --- __tests__/ActivitiesContainer.spec.tsx | 2 +- __tests__/AuthPresentation.spec.tsx | 48 ++++++ __tests__/JobsActivityCard.spec.tsx | 4 +- __tests__/JobsContainer.spec.tsx | 2 +- __tests__/RecentCardToggle.spec.tsx | 2 +- __tests__/RootPage.spec.tsx | 51 ++++++ __tests__/TasksContainer.spec.tsx | 14 +- e2e/dashboard.spec.ts | 14 +- e2e/fixtures.ts | 2 +- e2e/landing.spec.ts | 32 ++++ e2e/signin.spec.ts | 4 +- src/app/(auth)/layout.tsx | 2 +- src/app/dashboard/layout.tsx | 4 +- src/app/dashboard/page.tsx | 4 +- src/app/globals.css | 21 +-- src/app/page.tsx | 7 +- src/components/Header.tsx | 41 ++--- src/components/ListSkeleton.tsx | 20 +++ src/components/Loading.tsx | 14 +- src/components/NavLink.tsx | 14 +- src/components/ProfileDropdown.tsx | 19 ++- src/components/RecordsCount.tsx | 2 +- src/components/SearchInput.tsx | 3 +- src/components/Sidebar.tsx | 8 +- src/components/SidebarInset.tsx | 4 +- .../activities/ActivitiesContainer.tsx | 8 +- src/components/auth/AuthCard.tsx | 121 +++++++------- src/components/auth/SigninForm.tsx | 23 ++- src/components/auth/SignupForm.tsx | 21 +-- .../automations/AutomationContainer.tsx | 4 +- src/components/dashboard/ActivityCalendar.tsx | 2 +- src/components/dashboard/JobsActivityCard.tsx | 31 ++-- src/components/dashboard/JobsAppliedCard.tsx | 2 +- src/components/dashboard/RecentCardToggle.tsx | 39 ++--- .../dashboard/WeeklyBarChartToggle.tsx | 36 ++--- src/components/landing/FeaturesSection.tsx | 82 ++++++++++ src/components/landing/LandingPage.tsx | 152 ++++++++++++++++++ src/components/landing/ProductPreview.tsx | 67 ++++++++ src/components/myjobs/JobsContainer.tsx | 8 +- src/components/settings/AiSettings.tsx | 4 +- src/components/tasks/TasksContainer.tsx | 6 +- src/components/ui/card.tsx | 34 ++-- src/components/ui/skeleton.tsx | 16 ++ 43 files changed, 701 insertions(+), 293 deletions(-) create mode 100644 __tests__/AuthPresentation.spec.tsx create mode 100644 __tests__/RootPage.spec.tsx create mode 100644 e2e/landing.spec.ts create mode 100644 src/components/ListSkeleton.tsx create mode 100644 src/components/landing/FeaturesSection.tsx create mode 100644 src/components/landing/LandingPage.tsx create mode 100644 src/components/landing/ProductPreview.tsx create mode 100644 src/components/ui/skeleton.tsx diff --git a/__tests__/ActivitiesContainer.spec.tsx b/__tests__/ActivitiesContainer.spec.tsx index fa914dcbe..1f5a04065 100644 --- a/__tests__/ActivitiesContainer.spec.tsx +++ b/__tests__/ActivitiesContainer.spec.tsx @@ -381,7 +381,7 @@ describe("ActivitiesContainer Search Functionality", () => { // Should show loading indicator await waitFor(() => { - expect(screen.getByTestId("loader")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Activity records" })).toHaveAttribute("aria-busy", "true"); }); // Resolve the search diff --git a/__tests__/AuthPresentation.spec.tsx b/__tests__/AuthPresentation.spec.tsx new file mode 100644 index 000000000..ce1c642e7 --- /dev/null +++ b/__tests__/AuthPresentation.spec.tsx @@ -0,0 +1,48 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { authenticate, signup } from "@/actions/auth.actions"; +import SigninForm from "@/components/auth/SigninForm"; +import SignupForm from "@/components/auth/SignupForm"; + +vi.mock("@/actions/auth.actions", () => ({ + authenticate: vi.fn(), + signup: vi.fn(), +})); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + +it.each(["signin", "signup"])( + "keeps the %s submit label while pending and announces errors", + async (mode) => { + let finish!: (value: "Invalid credentials.") => void; + vi.mocked(authenticate).mockImplementationOnce( + () => + new Promise<"Invalid credentials.">((resolve) => { + finish = resolve; + }), + ); + vi.mocked(signup).mockResolvedValueOnce({ success: true }); + render(mode === "signin" ? : ); + if (mode === "signup") + fireEvent.change(screen.getByLabelText("Full Name"), { + target: { value: "Local Tester" }, + }); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "password123" }, + }); + const button = screen.getByRole("button", { + name: mode === "signin" ? "Login" : "Create an account", + }); + await act(async () => { + fireEvent.click(button); + }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + await act(async () => { + finish("Invalid credentials."); + }); + expect(button).toBeEnabled(); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid credentials."); + }, +); diff --git a/__tests__/JobsActivityCard.spec.tsx b/__tests__/JobsActivityCard.spec.tsx index 530face18..c56f6151a 100644 --- a/__tests__/JobsActivityCard.spec.tsx +++ b/__tests__/JobsActivityCard.spec.tsx @@ -107,7 +107,7 @@ describe("JobsActivityCard", () => { render(); const toggle = screen.getByTestId("jobs-activity-toggle-group"); - await user.click(within(toggle).getByRole("button", { name: "30d" })); + await user.click(within(toggle).getByRole("tab", { name: "30d" })); const total = screen.getByTestId("jobs-activity-total"); @@ -129,7 +129,7 @@ describe("JobsActivityCard", () => { render(); const toggle = screen.getByTestId("jobs-activity-toggle-group"); - await user.click(within(toggle).getByRole("button", { name: "30d" })); + await user.click(within(toggle).getByRole("tab", { name: "30d" })); const total = screen.getByTestId("jobs-activity-total"); diff --git a/__tests__/JobsContainer.spec.tsx b/__tests__/JobsContainer.spec.tsx index 39f2f74cf..a85fcb488 100644 --- a/__tests__/JobsContainer.spec.tsx +++ b/__tests__/JobsContainer.spec.tsx @@ -593,7 +593,7 @@ describe("JobsContainer Search Functionality", () => { // Should show loading indicator await waitFor(() => { - expect(screen.getByTestId("loader")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Job records" })).toHaveAttribute("aria-busy", "true"); }); // Resolve the search diff --git a/__tests__/RecentCardToggle.spec.tsx b/__tests__/RecentCardToggle.spec.tsx index e8f591829..d0141c5b9 100644 --- a/__tests__/RecentCardToggle.spec.tsx +++ b/__tests__/RecentCardToggle.spec.tsx @@ -27,7 +27,7 @@ describe("RecentCardToggle - Start Activity", () => { }; const switchToActivitiesTab = async () => { - await user.click(screen.getByRole("button", { name: "Activities" })); + await user.click(screen.getByRole("tab", { name: "Activities" })); }; beforeEach(() => { diff --git a/__tests__/RootPage.spec.tsx b/__tests__/RootPage.spec.tsx new file mode 100644 index 000000000..9986e4dc3 --- /dev/null +++ b/__tests__/RootPage.spec.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import db from "@/lib/db"; +import { redirect } from "next/navigation"; +import RootPage from "@/app/page"; + +const { mockAuth } = vi.hoisted(() => ({ mockAuth: vi.fn() })); +vi.mock("@/auth", () => ({ auth: mockAuth })); +vi.mock("@/lib/db", () => ({ default: { user: { count: vi.fn() } } })); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(() => { + throw new Error("redirect"); + }), +})); + +describe("public root", () => { + it("redirects authenticated visitors without querying public account state", async () => { + mockAuth.mockResolvedValue({ user: { id: "user-1" } }); + await expect(RootPage()).rejects.toThrow("redirect"); + expect(redirect).toHaveBeenCalledWith("/dashboard"); + expect(db.user.count).not.toHaveBeenCalled(); + }); + + it.each([0, 1])( + "renders the landing page with %i existing accounts", + async (count) => { + mockAuth.mockResolvedValue(null); + vi.mocked(db.user.count).mockResolvedValue(count); + render(await RootPage()); + expect( + screen.getByRole("heading", { + level: 1, + name: "Your self-hosted workspace for the job search.", + }), + ).toBeInTheDocument(); + for (const link of screen.getAllByRole("link", { name: "Get started" })) { + expect(link).toHaveAttribute( + "href", + count === 0 ? "/signup" : "/signin", + ); + } + expect(screen.getByRole("link", { name: "Sign in" })).toHaveAttribute( + "href", + "/signin", + ); + expect( + screen.getByRole("link", { name: "View source on GitHub" }), + ).toHaveAttribute("href", "https://github.com/Gsync/jobsync"); + expect(redirect).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/__tests__/TasksContainer.spec.tsx b/__tests__/TasksContainer.spec.tsx index ed97eabbd..94cca254e 100644 --- a/__tests__/TasksContainer.spec.tsx +++ b/__tests__/TasksContainer.spec.tsx @@ -219,7 +219,7 @@ describe("TasksContainer Component", () => { render(); - expect(screen.getByTestId("loader")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading records" })).toBeInTheDocument(); }); it("should show empty state when no tasks are found", async () => { @@ -248,7 +248,7 @@ describe("TasksContainer Component", () => { render(); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); }); @@ -283,7 +283,7 @@ describe("TasksContainer Component", () => { }); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); // Find and click the actions menu button for the first task @@ -314,7 +314,7 @@ describe("TasksContainer Component", () => { }); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); // Find and click the actions menu button for the first task @@ -369,7 +369,7 @@ describe("TasksContainer Component", () => { }); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); // Find and click the actions menu button for the first task @@ -396,7 +396,7 @@ describe("TasksContainer Component", () => { }); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); // Find and click the actions menu button for the first task @@ -427,7 +427,7 @@ describe("TasksContainer Component", () => { }); await waitFor(() => { - expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading records" })).not.toBeInTheDocument(); }); // Find and click the actions menu button for the first task diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 986bff3d6..3c1a47006 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -214,19 +214,19 @@ test.describe("Dashboard page", () => { page, }) => { const jobsActivityToggle = page.getByTestId("jobs-activity-toggle-group"); - await jobsActivityToggle.getByRole("button", { name: "30d" }).click(); + await jobsActivityToggle.getByRole("tab", { name: "30d" }).click(); await expect( - jobsActivityToggle.getByRole("button", { name: "30d" }), - ).toHaveClass(/bg-primary/); + jobsActivityToggle.getByRole("tab", { name: "30d" }), + ).toHaveAttribute("aria-selected", "true"); const recentToggle = page.getByTestId("recent-card-toggle-group"); - await recentToggle.getByRole("button", { name: "Activities" }).click(); + await recentToggle.getByRole("tab", { name: "Activities" }).click(); await expect( page.getByRole("heading", { name: "Recent Activities", exact: true }), ).toBeVisible(); const weeklyToggle = page.getByTestId("weekly-chart-toggle-group"); - await weeklyToggle.getByRole("button", { name: "Activities" }).click(); + await weeklyToggle.getByRole("tab", { name: "Activities" }).click(); await expect( page.getByRole("heading", { name: "Weekly Activities", exact: true }), ).toBeVisible(); @@ -268,7 +268,7 @@ test.describe("Dashboard page", () => { await navigateToDashboard(page); await page .getByTestId("recent-card-toggle-group") - .getByRole("button", { name: "Activities" }) + .getByRole("tab", { name: "Activities" }) .click(); await expect(page.getByText(taskTitle, { exact: true })).not.toBeVisible(); }); @@ -284,7 +284,7 @@ test.describe("Dashboard page", () => { await navigateToDashboard(page); await page .getByTestId("recent-card-toggle-group") - .getByRole("button", { name: "Activities" }) + .getByRole("tab", { name: "Activities" }) .click(); const activityRow = page diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 073f27f5e..1c13cfeae 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -164,7 +164,7 @@ type Fixtures = { export const test = base.extend({ // Auto-login every test and land on the dashboard. page: async ({ page, baseURL }, use) => { - await page.goto("/"); + await page.goto("/signin"); await login(page); await expect(page).toHaveURL(baseURL + "/dashboard"); await use(page); diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts new file mode 100644 index 000000000..65168ede7 --- /dev/null +++ b/e2e/landing.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test"; + +for (const width of [390, 1280]) { + for (const colorScheme of ["light", "dark"] as const) { + test(`public entry at ${width}px in ${colorScheme} mode`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ colorScheme }); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Your self-hosted workspace for the job search.", + ); + await expect(page.getByText("Example workspace")).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await page.getByRole("link", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/signin$/); + await expect( + page.getByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + await page + .getByRole("link", { name: "Create Account", exact: true }) + .click(); + await expect(page).toHaveURL(/\/signup$/); + await expect(page.getByLabel("Full Name")).toBeVisible(); + }); + } +} diff --git a/e2e/signin.spec.ts b/e2e/signin.spec.ts index 190a735a2..e449ed9f7 100644 --- a/e2e/signin.spec.ts +++ b/e2e/signin.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from "@playwright/test"; import { login } from "./fixtures"; test("Signin page has title", async ({ page }) => { - await page.goto("/"); + await page.goto("/signin"); await expect(page).toHaveTitle("Sign In | JobSync"); @@ -17,7 +17,7 @@ test("Signin page has title", async ({ page }) => { }); test("Signin and out from app", async ({ page, baseURL }) => { - await page.goto("/"); + await page.goto("/signin"); await login(page); await expect(page).toHaveURL(baseURL + "/dashboard"); diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index f0472515f..01a5b9c6b 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -4,7 +4,7 @@ export default function AuthLayout({ children: React.ReactNode; }>) { return ( -
+
{children}
); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index d6792aa47..97d7f87d3 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -54,8 +54,8 @@ export default async function RootLayout({ {/* Container, not viewport, queries: the docked chat panel shrinks this box without changing the viewport width. */} -
-
+
+
{children}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 06972245d..363263eb7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -39,8 +39,8 @@ export default async function Dashboard() { const activityCalendarDataKeys = Object.keys(activityCalendarData); return ( <> -
-
+
+
0} />; } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e335bb6d7..f74c0e5b1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { PanelLeft, Briefcase } from "lucide-react"; @@ -11,12 +13,16 @@ import { } from "@/components/ui/sheet"; import { SIDEBAR_LINKS } from "@/lib/constants"; import SidebarToggle from "./SidebarToggle"; +import NavLink from "./NavLink"; +import { usePathname } from "next/navigation"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { AgentChatTrigger } from "./AgentChatTrigger"; import { NotificationBell } from "./notifications/NotificationBell"; -async function Header() { +function Header() { + const pathname = usePathname(); return ( -
+
- + Navigation Menu - -

- JobSync - Job Search Assistant -

-
- {/* - */} -
+

JobSync

+ Job search workspace +
diff --git a/src/components/ListSkeleton.tsx b/src/components/ListSkeleton.tsx new file mode 100644 index 000000000..e5f8ff680 --- /dev/null +++ b/src/components/ListSkeleton.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ListSkeleton() { + return ( +
+ Loading records + {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+ ); +} diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 4e60a200b..eac7c083c 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -1,11 +1,9 @@ -import { Loader } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; -const Loading = () => { - return ( -
- -
- ); -}; +const Loading = () => ( + + + +); export default Loading; diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx index 622b728b4..1accc5d4f 100644 --- a/src/components/NavLink.tsx +++ b/src/components/NavLink.tsx @@ -9,7 +9,7 @@ import { import { LucideProps } from "lucide-react"; import { cn } from "@/lib/utils"; -interface NavLinkProps { +interface NavLinkProps extends Omit, "href"> { label: string; Icon: ForwardRefExoticComponent< Omit & RefAttributes @@ -19,7 +19,7 @@ interface NavLinkProps { expanded: boolean; } -function NavLink({ label, Icon, route, pathname, expanded }: NavLinkProps) { +function NavLink({ label, Icon, route, pathname, expanded, ...props }: NavLinkProps) { // "/dashboard" is a prefix of every other route, so it only matches exactly. const isActive = pathname === route || @@ -27,24 +27,24 @@ function NavLink({ label, Icon, route, pathname, expanded }: NavLinkProps) { const link = ( {isActive && ( )} {/* Fixed-width lead box (= collapsed rail width) so the icon sits at the same spot in both states and never moves during the slide. */} - + void; } @@ -47,10 +49,11 @@ export function ProfileDropdown({ - + {!expanded && {label}} @@ -108,8 +111,10 @@ export function ProfileDropdown({ {summary.unread > 0 && ( 0 ? "bg-destructive" : "bg-primary" + "ml-2 rounded-full px-1.5 text-[11px] font-semibold", + summary.unreadErrors > 0 + ? "bg-destructive-bg text-destructive-foreground" + : "bg-primary text-primary-foreground" )} > {summary.unread} @@ -149,7 +154,7 @@ export function ProfileDropdown({
diff --git a/src/components/RecordsCount.tsx b/src/components/RecordsCount.tsx index a32dd92ce..c1cd7f7e9 100644 --- a/src/components/RecordsCount.tsx +++ b/src/components/RecordsCount.tsx @@ -13,7 +13,7 @@ export function RecordsCount({ }: RecordsCountProps) { return (
- Showing 1 to {count} + Showing {count === 0 ? 0 : 1} to {count} {total !== undefined && ( <> {" "} diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx index 644bd0e08..a43b98b10 100644 --- a/src/components/SearchInput.tsx +++ b/src/components/SearchInput.tsx @@ -12,10 +12,11 @@ interface SearchInputProps { export function SearchInput({ value, onChange, placeholder }: SearchInputProps) { return (
- + onChange(e.target.value)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b4d1b541a..4d6be07e3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -26,13 +26,13 @@ function Sidebar({ user, signOutAction }: SidebarProps) {