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 ( + + +
+ Home + Adopt a Pet + Online Store + Schedule an Appointment + Contact Us + About Us +
+ +
+ {loading ? null : user ? ( + <> + + Hello, {user.fullName || user.username} + + + + ) : ( + <> + Log In + Register + + )} +
+ ); } \ 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; +}