{stats.map((s) => (
-
{s.value}
+
+ {s.value}
+
{s.label}
))}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d68fac9..a22c531 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,10 +1,14 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './styles/index.css'
-import App from './App.tsx'
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { RouterProvider } from "react-router-dom";
+import { AuthProvider } from "./auth/AuthContext";
+import { router } from "./router/Router";
+import "../src/styles/index.css";
-createRoot(document.getElementById('root')!).render(
+createRoot(document.getElementById("root")!).render(
-
- ,
-)
+
+
+
+
+);
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..0a85d5d
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,3 @@
+export default function Dashboard() {
+ return
Dashboard — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx
new file mode 100644
index 0000000..45bdbd2
--- /dev/null
+++ b/frontend/src/pages/NotFound.tsx
@@ -0,0 +1,9 @@
+export default function NotFound() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx
new file mode 100644
index 0000000..2bcd8ae
--- /dev/null
+++ b/frontend/src/pages/Reports.tsx
@@ -0,0 +1,3 @@
+export default function Reports() {
+ return
Reports — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx
new file mode 100644
index 0000000..b531787
--- /dev/null
+++ b/frontend/src/pages/auth/Login.tsx
@@ -0,0 +1,308 @@
+import { useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useAuth } from "../../auth/AuthContext";
+import { getRoleDestination } from "../../router/constants";
+import { extractErrorMessage } from "../../utils/errors";
+
+interface LocationState {
+ from?: { pathname: string };
+}
+
+export default function Login() {
+ const { login, isAuthenticated, user } = useAuth();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const emailRef = useRef
(null);
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // redirect if already authenticated
+ useEffect(() => {
+ if (isAuthenticated && user) {
+ const state = location.state as LocationState;
+ const destination =
+ state?.from?.pathname ?? getRoleDestination(user.role);
+ navigate(destination, { replace: true });
+ }
+ }, [isAuthenticated, user, navigate, location.state]);
+
+ // focus email on mount
+ useEffect(() => {
+ emailRef.current?.focus();
+ }, []);
+
+ useEffect(() => {
+ document.title = "Sign In — Gatelog";
+ }, []);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!email.trim() || !password) return;
+
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ await login(email.trim().toLowerCase(), password);
+ // navigation handled by the useEffect above once auth state updates
+ } catch (err: unknown) {
+ setError(extractErrorMessage(err));
+ setIsLoading(false);
+ }
+}
+
+return (
+
+ {/* ── Left panel ── */}
+
+ {/* subtle grid pattern */}
+
+
+ {/* green glow orb */}
+
+
+
+
+ Gatelog
+
+
+
+
+ Visitor Management
+
+
+ Every visitor. Every second. Accounted for.
+
+
+ The front desk tool that knows who walked in, who is still here,
+ and who left without signing out.
+
+
+
+
+ 30s
+ Avg. check-in time
+
+
+
+ 3
+ Access levels
+
+
+
+ 0
+ Paper needed
+
+
+
+
+
+
+
+
+ {/* ── Right — form ── */}
+
+
+ {/* Mobile logo */}
+
+ Gatelog
+
+
+
+
+ Welcome back
+
+
+ Sign in to your Gatelog account
+
+
+
+
+
+
+ Need access?{" "}
+
+ Request a demo
+
+
+
+
+
+);
+}
+
+/* ── Icons ── */
+function MailIcon() {
+ return (
+
+ );
+}
+
+function LockIcon() {
+ return (
+
+ );
+}
+
+function EyeIcon() {
+ return (
+
+ );
+}
+
+function EyeOffIcon() {
+ return (
+
+ );
+}
+
+function ArrowRight() {
+ return (
+
+ );
+}
+
+function AlertIcon() {
+ return (
+
+ );
+}
+
+function Spinner() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/auth/Unauthorized.tsx b/frontend/src/pages/auth/Unauthorized.tsx
new file mode 100644
index 0000000..f766833
--- /dev/null
+++ b/frontend/src/pages/auth/Unauthorized.tsx
@@ -0,0 +1,28 @@
+import { useNavigate } from "react-router-dom";
+import { useAuth } from "../../auth/AuthContext";
+import { getRoleDestination } from "../../router/constants";
+
+export default function Unauthorized() {
+ const { user, logout } = useAuth();
+ const navigate = useNavigate();
+
+ function goHome() {
+ if (user) navigate(getRoleDestination(user.role), { replace: true });
+ else navigate("/", { replace: true });
+ }
+
+ return (
+
+
Access Denied
+
You do not have permission to view this page.
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/sites/Sites.tsx b/frontend/src/pages/sites/Sites.tsx
new file mode 100644
index 0000000..fe5258f
--- /dev/null
+++ b/frontend/src/pages/sites/Sites.tsx
@@ -0,0 +1,3 @@
+export default function Sites() {
+ return Sites — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/users/Admin.tsx b/frontend/src/pages/users/Admin.tsx
new file mode 100644
index 0000000..c2161f2
--- /dev/null
+++ b/frontend/src/pages/users/Admin.tsx
@@ -0,0 +1,3 @@
+export default function Admin() {
+ return Admin — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/users/Users.tsx b/frontend/src/pages/users/Users.tsx
new file mode 100644
index 0000000..6109373
--- /dev/null
+++ b/frontend/src/pages/users/Users.tsx
@@ -0,0 +1,3 @@
+export default function Users() {
+ return Users — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/visitors/NewVisitor.tsx b/frontend/src/pages/visitors/NewVisitor.tsx
new file mode 100644
index 0000000..e3d770a
--- /dev/null
+++ b/frontend/src/pages/visitors/NewVisitor.tsx
@@ -0,0 +1,3 @@
+export default function NewVisitor() {
+ return New Visitor — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/visitors/VisitorList.tsx b/frontend/src/pages/visitors/VisitorList.tsx
new file mode 100644
index 0000000..70739fd
--- /dev/null
+++ b/frontend/src/pages/visitors/VisitorList.tsx
@@ -0,0 +1,3 @@
+export default function VisitorList() {
+ return Visitor List — coming soon
;
+}
\ No newline at end of file
diff --git a/frontend/src/router/RoleRedirect.tsx b/frontend/src/router/RoleRedirect.tsx
new file mode 100644
index 0000000..7f7c243
--- /dev/null
+++ b/frontend/src/router/RoleRedirect.tsx
@@ -0,0 +1,8 @@
+import { Navigate } from "react-router-dom";
+import { useAuth } from "../auth/AuthContext";
+import { getRoleDestination } from "./constants";
+
+export default function RoleRedirect() {
+ const { user } = useAuth();
+ return ;
+}
\ No newline at end of file
diff --git a/frontend/src/router/Router.tsx b/frontend/src/router/Router.tsx
new file mode 100644
index 0000000..f39ec21
--- /dev/null
+++ b/frontend/src/router/Router.tsx
@@ -0,0 +1,68 @@
+import { createBrowserRouter } from "react-router-dom";
+import Landing from "../pages/Landing";
+import Login from "../pages/auth/Login";
+import Unauthorized from "../pages/auth/Unauthorized";
+import NotFound from "../pages/NotFound";
+import ProtectedRoute from "../auth/ProtectedRoute";
+import { ROUTE_PATHS } from "./constants";
+
+export const router = createBrowserRouter([
+ { path: ROUTE_PATHS.ROOT, element: },
+ { path: ROUTE_PATHS.LOGIN, element: },
+ { path: ROUTE_PATHS.UNAUTHORIZED, element: },
+
+ {
+ element: ,
+ children: [
+ {
+ path: ROUTE_PATHS.NEW_VISITOR,
+ lazy: () => import("../pages/visitors/NewVisitor")
+ .then((m) => ({ Component: m.default })),
+ },
+ {
+ path: ROUTE_PATHS.VISITORS,
+ lazy: () => import("../pages/visitors/VisitorList")
+ .then((m) => ({ Component: m.default })),
+ },
+ ],
+ },
+
+ {
+ element: ,
+ children: [
+ {
+ path: ROUTE_PATHS.DASHBOARD,
+ lazy: () => import("../pages/Dashboard")
+ .then((m) => ({ Component: m.default })),
+ },
+ {
+ path: ROUTE_PATHS.REPORTS,
+ lazy: () => import("../pages/Reports")
+ .then((m) => ({ Component: m.default })),
+ },
+ {
+ path: ROUTE_PATHS.USERS,
+ lazy: () => import("../pages/users/Users")
+ .then((m) => ({ Component: m.default })),
+ },
+ ],
+ },
+
+ {
+ element: ,
+ children: [
+ {
+ path: ROUTE_PATHS.ADMIN,
+ lazy: () => import("../pages/users/Admin")
+ .then((m) => ({ Component: m.default })),
+ },
+ {
+ path: ROUTE_PATHS.SITES,
+ lazy: () => import("../pages/sites/Sites")
+ .then((m) => ({ Component: m.default })),
+ },
+ ],
+ },
+
+ { path: "*", element: },
+]);
\ No newline at end of file
diff --git a/frontend/src/router/constants.ts b/frontend/src/router/constants.ts
new file mode 100644
index 0000000..50d28f1
--- /dev/null
+++ b/frontend/src/router/constants.ts
@@ -0,0 +1,26 @@
+import { type Role } from "../auth/AuthContext";
+
+export const ROUTE_PATHS = {
+ ROOT: "/",
+ LOGIN: "/login",
+ UNAUTHORIZED: "/unauthorized",
+ ADMIN: "/admin",
+ DASHBOARD: "/dashboard",
+ VISITORS: "/visitors",
+ NEW_VISITOR: "/visitors/new",
+ REPORTS: "/reports",
+ USERS: "/users",
+ SITES: "/sites",
+} as const;
+
+export const ROLE_DESTINATION_MAP: Record = {
+ SUPER_ADMIN: ROUTE_PATHS.ADMIN,
+ MANAGER: ROUTE_PATHS.DASHBOARD,
+ STAFF: ROUTE_PATHS.NEW_VISITOR,
+};
+
+export function getRoleDestination(role: Role | undefined): string {
+ return role && role in ROLE_DESTINATION_MAP
+ ? ROLE_DESTINATION_MAP[role]
+ : ROUTE_PATHS.LOGIN;
+}
\ No newline at end of file
diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts
new file mode 100644
index 0000000..3e1e79d
--- /dev/null
+++ b/frontend/src/utils/errors.ts
@@ -0,0 +1,65 @@
+interface ApiError {
+ response?: {
+ status?: number;
+ data?: {
+ message?: string;
+ fieldErrors?: Record;
+ };
+ };
+ message?: string;
+}
+
+export function extractErrorMessage(err: unknown): string {
+ const error = err as ApiError;
+
+ // no response at all — network failure, server down, CORS block
+ if (!error.response) {
+ return "Unable to reach the server. Check your connection and try again.";
+ }
+
+ const { status, data } = error.response;
+
+ // use the backend's own message if it exists — your GlobalExceptionHandler
+ // always returns a structured { message: string } body
+ const backendMessage = data?.message;
+
+ switch (status) {
+ case 400:
+ // validation failure — check for field-level errors first
+ if (data?.fieldErrors) {
+ const first = Object.values(data.fieldErrors)[0];
+ return first ?? backendMessage ?? "Please check your input and try again.";
+ }
+ return backendMessage ?? "Invalid request. Please check your input.";
+
+ case 401:
+ // your InvalidCredentialsException maps to 401
+ return backendMessage ?? "Invalid email or password.";
+
+ case 403:
+ // AccountDisabledException maps to 403
+ return backendMessage ?? "Your account has been deactivated. Contact your administrator.";
+
+ case 404:
+ return backendMessage ?? "The requested resource was not found.";
+
+ case 409:
+ // ConflictException — duplicate email etc
+ return backendMessage ?? "A conflict occurred. Please try again.";
+
+ case 422:
+ // InvalidStateException
+ return backendMessage ?? "This action cannot be completed in the current state.";
+
+ case 429:
+ return "Too many attempts. Please wait a moment and try again.";
+
+ case 500:
+ case 502:
+ case 503:
+ return "The server encountered an error. Please try again in a moment.";
+
+ default:
+ return backendMessage ?? "Something went wrong. Please try again later.";
+ }
+}
\ No newline at end of file