diff --git a/web/app/globals.css b/web/app/globals.css
index 7e93dfb2..465c429d 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -773,3 +773,250 @@ body {
margin: 0;
line-height: 1.5;
}
+
+/* Auth/nav */
+
+.navbar {
+ justify-content: space-between;
+}
+
+.nav-auth {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-left: auto;
+ padding-left: 1.5rem;
+ flex-shrink: 0;
+}
+
+.nav-greeting {
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.nav-register-btn {
+ background: white;
+ color: orange !important;
+ font-weight: 600;
+ border-radius: 20px;
+ padding: 0.4rem 1rem !important;
+}
+
+.nav-register-btn:hover {
+ background: #fff3e0 !important;
+}
+
+.nav-logout-btn {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ border-radius: 20px;
+ padding: 0.35rem 1rem;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: background 0.2s ease;
+ white-space: nowrap;
+}
+
+.nav-logout-btn:hover {
+ background: rgba(255, 255, 255, 0.35);
+}
+
+/* Login/Register */
+
+.auth-page {
+ min-height: calc(100vh - 70px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+ background: #fafafa;
+}
+
+.auth-card {
+ background: white;
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
+ padding: 2.5rem;
+ width: 100%;
+ max-width: 440px;
+}
+
+.auth-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #222;
+ margin: 0 0 1.5rem;
+ text-align: center;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.auth-label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #444;
+}
+
+.auth-input {
+ padding: 0.6rem 0.85rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+ outline: none;
+}
+
+.auth-input:focus {
+ border-color: orange;
+ box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2);
+}
+
+.auth-submit-btn {
+ margin-top: 0.5rem;
+ padding: 0.75rem;
+ background: orange;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.2s ease, transform 0.1s ease;
+}
+
+.auth-submit-btn:hover:not(:disabled) {
+ background: #e69500;
+}
+
+.auth-submit-btn:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.auth-submit-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.auth-error {
+ background: #fff0f0;
+ border: 1px solid #f5c6c6;
+ color: #c0392b;
+ border-radius: 8px;
+ padding: 0.65rem 1rem;
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+}
+
+.auth-switch {
+ text-align: center;
+ font-size: 0.9rem;
+ color: #666;
+ margin-top: 1.25rem;
+}
+
+.auth-switch-link {
+ color: orange;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.auth-switch-link:hover {
+ text-decoration: underline;
+}
+
+/* User Profile Page */
+
+.profile-card {
+ background: white;
+ border-radius: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
+ padding: 2.5rem;
+ width: 100%;
+ max-width: 480px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.profile-avatar-circle {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: orange;
+ color: white;
+ font-size: 2rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 0.25rem;
+}
+
+.profile-name {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #222;
+ margin: 0;
+}
+
+.profile-role-badge {
+ display: inline-block;
+ background: #fff3e0;
+ color: #e67e00;
+ border: 1px solid #ffd180;
+ border-radius: 20px;
+ padding: 0.2rem 0.85rem;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.profile-fields {
+ width: 100%;
+ margin: 0.75rem 0 0;
+ border-top: 1px solid #eee;
+ padding-top: 1rem;
+}
+
+.profile-field-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 0.55rem 0;
+ border-bottom: 1px solid #f0f0f0;
+ gap: 1rem;
+}
+
+.profile-field-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #888;
+ flex-shrink: 0;
+}
+
+.profile-field-value {
+ font-size: 0.95rem;
+ color: #222;
+ text-align: right;
+ word-break: break-word;
+}
+
+.profile-logout-btn {
+ width: 100%;
+ margin-top: 1rem;
+}
+
+.profile-loading {
+ color: #888;
+ font-size: 1rem;
+}
diff --git a/web/app/layout.js b/web/app/layout.js
index 86efbb81..0a50c8db 100644
--- a/web/app/layout.js
+++ b/web/app/layout.js
@@ -1,18 +1,21 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import DisplayNav from "@/components/Navigation";
+import ClientProviders from "@/components/ClientProviders";
export const metadata = {
title: "Leon's Pet Store",
description: "Generated by create next app",
};
-export default function RootLayout({ children }) {
+export default function RootLayout({children}) {
return (
-
- {children}
+
+
+ {children}
+
);
diff --git a/web/app/login/page.js b/web/app/login/page.js
new file mode 100644
index 00000000..32c05c52
--- /dev/null
+++ b/web/app/login/page.js
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
+
+export default function LoginPage() {
+ const {login} = useAuth();
+ const router = useRouter();
+
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+
+ try {
+ await login(username, password);
+ router.push("/");
+ }
+
+ catch (err) {
+ setError(err.message);
+ }
+
+ finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
Log In
+
+ {error &&
{error}
}
+
+
+
+
+ Don't have an account?{" "}
+ Register here
+
+
+
+ );
+}
diff --git a/web/app/page.js b/web/app/page.js
index 6aea5518..4819bd7d 100644
--- a/web/app/page.js
+++ b/web/app/page.js
@@ -7,10 +7,10 @@ import { useState, useEffect } from "react";
export default function Home() {
//Slideshow images array
const slideshowImages = [
- { src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets" },
- { src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies" },
- { src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming" },
- { src: "/images/home/slideshow/pet4.jpg", alt: "Pet food" },
+ {src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
+ {src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"},
+ {src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"},
+ {src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
];
const [currentSlide, setCurrentSlide] = useState(0);
@@ -25,10 +25,10 @@ export default function Home() {
//Hyperlinks to other pages
const navImages = [
- { src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet" },
- { src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/store", title: "Online Store" },
- { src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments" },
- { src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us" },
+ {src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"},
+ {src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/store", title: "Online Store"},
+ {src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"},
+ {src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us"},
];
return (
diff --git a/web/app/profile/page.js b/web/app/profile/page.js
new file mode 100644
index 00000000..fcc903cc
--- /dev/null
+++ b/web/app/profile/page.js
@@ -0,0 +1,61 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
+
+export default function ProfilePage() {
+ const { user, loading, logout } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!loading && !user) {
+ router.replace("/login");
+ }
+ }, [user, loading, router]);
+
+ function handleLogout() {
+ logout();
+
+ router.push("/");
+ }
+
+ if (loading || !user) {
+ return Loading…
;
+ }
+
+ const fields = [
+ {label: "Full Name", value: user.fullName},
+ {label: "Username", value: user.username},
+ {label: "Email", value: user.email},
+ {label: "Phone", value: user.phone || "—"},
+ {label: "Role", value: user.role},
+ ...(user.storeName ? [{label: "Store", value: user.storeName}] : []),
+ ];
+
+ return (
+
+
+
+ {(user.fullName || user.username).charAt(0).toUpperCase()}
+
+
+
{user.fullName || user.username}
+
{user.role}
+
+
+ {fields.map(({ label, value }) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/web/app/register/page.js b/web/app/register/page.js
new file mode 100644
index 00000000..20dcd2c1
--- /dev/null
+++ b/web/app/register/page.js
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
+
+export default function RegisterPage() {
+ const {register} = useAuth();
+ const router = useRouter();
+
+ const [form, setForm] = useState({
+ fullName: "",
+ username: "",
+ email: "",
+ phone: "",
+ password: "",
+ confirmPassword: "",});
+
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ function handleChange(e) {
+ setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError("");
+
+ if (form.password !== form.confirmPassword) {
+ setError("Passwords do not match.");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await register({fullName: form.fullName,
+ username: form.username,
+ email: form.email,
+ phone: form.phone,
+ password: form.password,
+ });
+ router.push("/");
+ }
+
+ catch (err) {
+ setError(err.message);
+ }
+
+ finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
Create Account
+
+ {error &&
{error}
}
+
+
+
+
+ Already have an account?{" "}
+ Log in here
+
+
+
+ );
+}
diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js
new file mode 100644
index 00000000..64e8157a
--- /dev/null
+++ b/web/components/ClientProviders.js
@@ -0,0 +1,7 @@
+"use client";
+
+import { AuthProvider } from "@/context/AuthContext";
+
+export default function ClientProviders({children}) {
+ return {children};
+}
diff --git a/web/components/Navigation.js b/web/components/Navigation.js
index f7de55e8..2e7f7cac 100644
--- a/web/components/Navigation.js
+++ b/web/components/Navigation.js
@@ -1,26 +1,53 @@
-import Link from "next/link";
+"use client";
+
import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
export default function DisplayNav() {
+ const {user, logout, loading} = useAuth();
+ const router = useRouter();
+
+ function handleLogout() {
+ logout();
+ router.push("/");
+ }
+
return (
+
+
+
+
+
+
);
}
\ No newline at end of file
diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js
new file mode 100644
index 00000000..5a9b0d08
--- /dev/null
+++ b/web/context/AuthContext.js
@@ -0,0 +1,107 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, useCallback } from "react";
+
+const AuthContext = createContext(null);
+
+const TOKEN_KEY = "auth_token";
+
+async function fetchCurrentUser(token) {
+ const res = await fetch("/api/v1/auth/me", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) return null;
+ return res.json();
+}
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(null);
+ const [token, setToken] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const stored = localStorage.getItem(TOKEN_KEY);
+ if (!stored) {
+ setLoading(false);
+
+ return;
+ }
+ fetchCurrentUser(stored)
+ .then((data) => {
+ if (data) {
+ setToken(stored);
+ setUser(data);
+ }
+
+ else {
+ localStorage.removeItem(TOKEN_KEY);
+ }
+ }).catch(() => localStorage.removeItem(TOKEN_KEY)).finally(() => setLoading(false));}, []);
+
+ const login = useCallback(async (username, password) => {
+ const res = await fetch("/api/v1/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.message || "Login failed");
+ }
+
+ const jwt = data.token;
+ localStorage.setItem(TOKEN_KEY, jwt);
+ setToken(jwt);
+
+ const userInfo = await fetchCurrentUser(jwt);
+
+ setUser(userInfo);
+
+ return userInfo;
+ }, []);
+
+ const register = useCallback(async ({ username, password, email, fullName, phone }) => {
+ const res = await fetch("/api/v1/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password, email, fullName, phone }),
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.message || "Registration failed");
+ }
+
+ const jwt = data.token;
+
+ localStorage.setItem(TOKEN_KEY, jwt);
+ setToken(jwt);
+
+ const userInfo = await fetchCurrentUser(jwt);
+ setUser(userInfo);
+
+ return userInfo;
+ }, []);
+
+ const logout = useCallback(() => {
+ localStorage.removeItem(TOKEN_KEY);
+ setToken(null);
+ setUser(null);}, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+
+ return ctx;
+}