Files
group-2-threaded-project-pe…/web/app/profile/page.js

668 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Author: Shiv
// Date: April 2026
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
//Species and breed options for the add/edit pet form
const SPECIES_BREEDS = {
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
Other: ["Other"],
};
const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[#444]";
const inputCls = "px-[0.85rem] py-[0.6rem] bg-white border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const selectCls = `custom-select ${inputCls} bg-white cursor-pointer`;
const errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem]";
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]";
const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Profile page - shows user info, edit form, avatar upload, owned pets, and order history
export default function ProfilePage() {
const {user, token, loading, logout, refreshUser} = useAuth();
const router = useRouter();
const petImageObjectUrlsRef = useRef([]);
const [pets, setPets] = useState([]);
const [loadingPets, setLoadingPets] = useState(false);
const [orders, setOrders] = useState([]);
const [loadingOrders, setLoadingOrders] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingPet, setEditingPet] = useState(null);
const [petName, setPetName] = useState("");
const [species, setSpecies] = useState("");
const [breed, setBreed] = useState("");
const [petAge, setPetAge] = useState("1");
const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null);
const [avatarObjectUrl, setAvatarObjectUrl] = useState(null);
const [profileForm, setProfileForm] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
const [profileSubmitting, setProfileSubmitting] = useState(false);
const [profileError, setProfileError] = useState(null);
const [profileSuccess, setProfileSuccess] = useState(null);
const [avatarSubmitting, setAvatarSubmitting] = useState(false);
//Revokes all blob URLs created for pet images to free up memory
const clearPetImageObjectUrls = useCallback(() => {
for (const objectUrl of petImageObjectUrlsRef.current) {
URL.revokeObjectURL(objectUrl);
}
petImageObjectUrlsRef.current = [];
}, []);
useEffect(() => {
if (!loading && !user) {
router.replace(`/login?next=${encodeURIComponent("/profile")}`);
}
}, [user, loading, router]);
useEffect(() => {
setProfileForm({
firstName: user?.firstName || "",
lastName: user?.lastName || "",
email: user?.email || "",
phone: user?.phone || "",
password: "",
confirmPassword: "",
});
}, [user]);
//Fetches the user's owned pets and resolves their images into blob URLs
const loadPets = useCallback(async () => {
if (!token) return;
setLoadingPets(true);
try {
const response = await fetch(`${API_BASE}/api/v1/my-pets?status=Owned`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
const petData = await response.json();
clearPetImageObjectUrls();
const ownedPets = Array.isArray(petData) ? petData : [];
const petsWithResolvedImages = await Promise.all(
ownedPets.map(async (pet) => {
if (!pet.imageUrl) return pet;
try {
const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!imageResponse.ok) return { ...pet, imageUrl: null };
const blob = await imageResponse.blob();
const objectUrl = URL.createObjectURL(blob);
petImageObjectUrlsRef.current.push(objectUrl);
return { ...pet, imageUrl: objectUrl };
} catch {
return { ...pet, imageUrl: null };
}
})
);
setPets(petsWithResolvedImages);
}
catch {
}
finally {
setLoadingPets(false);
}
}, [token, clearPetImageObjectUrls]);
useEffect(() => {
return () => {
clearPetImageObjectUrls();
};
}, [clearPetImageObjectUrls]);
//Fetches the user's recent order history
const loadOrders = useCallback(async () => {
if (!token) return;
setLoadingOrders(true);
try {
const res = await fetch(`${API_BASE}/api/v1/sales/my?size=20&sort=saleDate,desc`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
setOrders(data.content ?? []);
} catch { } finally {
setLoadingOrders(false);
}
}, [token]);
useEffect(() => {
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
loadPets();
loadOrders();
}
}, [user, loadPets, loadOrders]);
useEffect(() => {
let objectUrl = null;
if (user?.avatarUrl && token) {
fetch(`${API_BASE}${user.avatarUrl}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setAvatarObjectUrl(objectUrl);
} else {
setAvatarObjectUrl(null);
}
})
.catch(() => setAvatarObjectUrl(null));
} else {
setAvatarObjectUrl(null);
}
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [user?.avatarUrl, token]);
//Logs out and sends the user to the home page
function handleLogout() {
logout();
router.push("/");
}
//Saves changes to the user's name, email, phone, or password
async function handleProfileSubmit(e) {
e.preventDefault();
setProfileError(null);
if (profileForm.password && profileForm.password !== profileForm.confirmPassword) {
setProfileError("Passwords do not match.");
return;
}
setProfileSubmitting(true);
setProfileSuccess(null);
const payload = {
firstName: profileForm.firstName,
lastName: profileForm.lastName,
email: profileForm.email,
phone: profileForm.phone,
};
if (profileForm.password) {
payload.password = profileForm.password;
}
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || `Request failed (${res.status})`);
}
await refreshUser();
setProfileForm((prev) => ({ ...prev, password: "", confirmPassword: "" }));
setProfileSuccess("Profile updated successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setProfileSubmitting(false);
}
}
//Uploads a new avatar image for the user
async function handleAvatarUpload(file) {
if (!file) return;
const formData = new FormData();
formData.append("avatar", file);
setAvatarSubmitting(true);
setProfileError(null);
setProfileSuccess(null);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || "Failed to upload avatar");
}
await refreshUser();
setProfileSuccess(data?.message || "Avatar updated successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setAvatarSubmitting(false);
}
}
//Removes the user's avatar
async function handleAvatarDelete() {
setAvatarSubmitting(true);
setProfileError(null);
setProfileSuccess(null);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || "Failed to delete avatar");
}
await refreshUser();
setProfileSuccess(data?.message || "Avatar removed successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setAvatarSubmitting(false);
}
}
//Opens the add pet form with blank fields
function openAddForm() {
setEditingPet(null);
setPetName("");
setSpecies("");
setBreed("");
setPetAge("1");
setPetError(null);
setShowForm(true);
}
//Opens the edit pet form pre-filled with the selected pet's details
function openEditForm(pet) {
setEditingPet(pet);
setPetName(pet.petName);
setSpecies(pet.species);
setBreed(pet.breed || "");
setPetAge(pet.petAge != null ? String(pet.petAge) : "1");
setPetError(null);
setShowForm(true);
}
function closeForm() {
setShowForm(false);
setEditingPet(null);
setPetError(null);
}
//Creates a new pet or saves edits to an existing one
async function handlePetSubmit(e) {
e.preventDefault();
setPetError(null);
setSubmitting(true);
const url = editingPet
? `${API_BASE}/api/v1/my-pets/${editingPet.customerPetId}`
: `${API_BASE}/api/v1/my-pets`;
try {
const res = await fetch(url, {
method: editingPet ? "PUT" : "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ petName, species, breed: breed || null, petAge: Number(petAge) }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Request failed (${res.status})`);
}
closeForm();
loadPets();
}
catch (err) {
setPetError(err.message);
}
finally {
setSubmitting(false);
}
}
//Confirms with the user then removes the pet profile
async function handleDeletePet(id) {
if (!confirm("Remove this pet profile?")) return;
try {
const res = await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Failed to remove pet (${res.status})`);
}
loadPets();
}
catch (err) {
alert(err.message);
}
}
//Uploads a photo for a specific pet profile
async function handleImageUpload(petId, file) {
const formData = new FormData();
formData.append("image", file);
try {
const res = await fetch(`${API_BASE}/api/v1/my-pets/${petId}/image`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
alert(data?.message || "Failed to upload image");
return;
}
loadPets();
}
catch {
alert("Failed to upload image");
}
}
if (loading || !user) {
return (
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center">
<p className="text-[#666] text-[1.1rem]">Loading</p>
</main>
);
}
const displayName = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
const fields = [
{label: "First Name", value: user.firstName || "N/A"},
{label: "Last Name", value: user.lastName || "N/A"},
{label: "Username", value: user.username},
{label: "Email", value: user.email},
{label: "Phone", value: user.phone || "N/A"},
...(user.storeName ? [{ label: "Store", value: user.storeName }] : []),
...(user.role === "CUSTOMER" ? [{ label: "Loyalty Points", value: user.loyaltyPoints ?? 0 }] : []),
];
return (
<main className="min-h-screen max-w-[1000px] mx-auto px-8 py-10 flex flex-col gap-8">
{/* Profile card */}
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8">
<div className="w-20 h-20 rounded-full bg-[#e68672] text-white text-3xl font-bold flex items-center justify-center mx-auto mb-4 overflow-hidden">
{avatarObjectUrl ? (
<img src={avatarObjectUrl} alt={displayName} className="w-full h-full object-cover" />
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
<h1 className="text-2xl font-bold text-[#222] text-center mb-1">{displayName}</h1>
<div className="flex justify-center mb-6">
<span className="bg-[#f0f0f0] text-[#555] text-[0.75rem] font-semibold rounded-full px-3 py-1">{user.role}</span>
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 mb-6 bg-[#f9f9f9] rounded-xl p-4 max-[480px]:grid-cols-1">
{fields.map(({ label, value }) => (
<div key={label} className="flex gap-2 py-1">
<dt className="text-[0.85rem] font-semibold text-[#888] min-w-[100px]">{label}</dt>
<dd className="text-[0.9rem] text-[#333] m-0 break-words min-w-0">{value}</dd>
</div>
))}
</dl>
<form className="flex flex-col gap-4" onSubmit={handleProfileSubmit}>
<h2 className="text-[1.1rem] font-bold text-[#333] mb-0">Update Profile</h2>
{profileError && <div className={errorCls}>{profileError}</div>}
{profileSuccess && <div className={successCls}>{profileSuccess}</div>}
<label className={labelCls}>
First Name
<input className={inputCls} type="text" value={profileForm.firstName} onChange={(e) => setProfileForm((c) => ({ ...c, firstName: e.target.value }))} maxLength={50} />
</label>
<label className={labelCls}>
Last Name
<input className={inputCls} type="text" value={profileForm.lastName} onChange={(e) => setProfileForm((c) => ({ ...c, lastName: e.target.value }))} maxLength={50} />
</label>
<label className={labelCls}>
Email
<input className={inputCls} type="email" value={profileForm.email} onChange={(e) => setProfileForm((c) => ({ ...c, email: e.target.value }))} required />
</label>
<label className={labelCls}>
Phone
<input className={inputCls} type="text" value={profileForm.phone} onChange={(e) => setProfileForm((c) => ({ ...c, phone: e.target.value }))} maxLength={20} />
</label>
<label className={labelCls}>
New Password
<input className={inputCls} type="password" value={profileForm.password} onChange={(e) => setProfileForm((c) => ({ ...c, password: e.target.value }))} minLength={6} autoComplete="new-password" placeholder="Leave blank to keep current" />
</label>
<label className={labelCls}>
Confirm New Password
<input className={inputCls} type="password" value={profileForm.confirmPassword} onChange={(e) => setProfileForm((c) => ({ ...c, confirmPassword: e.target.value }))} autoComplete="new-password" placeholder="Leave blank to keep current" />
</label>
<div className="flex gap-3 items-center flex-wrap">
<label className="cursor-pointer px-4 py-2 bg-[#e68672] text-white rounded-lg text-[0.85rem] font-semibold hover:bg-[#d4705e] transition-colors">
<input
type="file"
accept="image/jpeg,image/png,image/gif"
className="hidden"
onChange={(e) => {
handleAvatarUpload(e.target.files?.[0] || null);
e.target.value = "";
}}
/>
{avatarSubmitting ? "Working..." : user.avatarUrl ? "Change Avatar" : "Upload Avatar"}
</label>
{user.avatarUrl && (
<button type="button" className="px-4 py-2 border border-[#f5c6c6] rounded-lg bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50" onClick={handleAvatarDelete} disabled={avatarSubmitting}>
Remove Avatar
</button>
)}
</div>
<button type="submit" className={submitBtnCls} disabled={profileSubmitting || avatarSubmitting}>
{profileSubmitting ? "Saving..." : "Save Profile"}
</button>
</form>
<button type="button" className="mt-4 w-full py-3 bg-[#555] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#333] active:scale-[0.98]" onClick={handleLogout}>
Log Out
</button>
</div>
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-[#333] m-0">My Pets</h2>
<button type="button" className="px-4 py-2 bg-[#e68672] text-white rounded-lg text-[0.9rem] font-semibold border-none cursor-pointer hover:bg-[#d4705e] transition-colors" onClick={openAddForm}>+ Add Pet</button>
</div>
{showForm && (
<form className="bg-[#f9f9f9] rounded-xl p-6 mb-6 flex flex-col gap-4" onSubmit={handlePetSubmit}>
<h3 className="text-[1.1rem] font-bold text-[#333] m-0">
{editingPet ? "Edit Pet" : "Add a New Pet"}
</h3>
{petError && <div className={errorCls}>{petError}</div>}
<label className={labelCls}>
Name
<input className={inputCls} type="text" value={petName} onChange={(e) => setPetName(e.target.value)} required maxLength={50} />
</label>
<label className={labelCls}>
Species
<select className={selectCls} value={species} onChange={(e) => { setSpecies(e.target.value); setBreed(""); }} required>
<option value="">Select a species...</option>
{Object.keys(SPECIES_BREEDS).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label className={labelCls}>
Breed
<select className={`${selectCls} disabled:bg-[#f5f5f5] disabled:text-[#aaa] disabled:cursor-not-allowed`} value={breed} onChange={(e) => setBreed(e.target.value)} required disabled={!species}>
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
{(SPECIES_BREEDS[species] || []).map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
<label className={labelCls}>
Age (years)
<select className={selectCls} value={petAge} onChange={(e) => setPetAge(e.target.value)} required>
{Array.from({ length: 20 }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
<div className="flex gap-3">
<button type="submit" className={`${submitBtnCls} px-5 py-2 text-[0.9rem]`} disabled={submitting}>
{submitting ? "Saving..." : editingPet ? "Save Changes" : "Add Pet"}
</button>
<button type="button" className="px-4 py-2 border border-[#ddd] rounded-lg bg-white text-[#555] text-[0.9rem] cursor-pointer hover:border-[#aaa] transition-colors" onClick={closeForm}>
Cancel
</button>
</div>
</form>
)}
{loadingPets ? (
<p className="text-[#666] text-center py-4">Loading pets...</p>
) : pets.length === 0 && !showForm ? (
<p className="text-center text-[#888] py-8">No pet profiles yet. Add your first pet above!</p>
) : (
<div className="grid grid-cols-3 gap-4 max-[768px]:grid-cols-2 max-[480px]:grid-cols-1">
{pets.map((pet) => (
<div key={pet.customerPetId} className="bg-[#f9f9f9] rounded-xl overflow-hidden border border-[#eee]">
<div className="relative aspect-square overflow-hidden bg-[#ececec]">
{pet.imageUrl ? (
<img src={pet.imageUrl} alt={pet.petName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">🐾</div>
)}
<label className="absolute bottom-2 right-2 bg-white rounded-full w-8 h-8 flex items-center justify-center cursor-pointer shadow text-base">
<input
type="file"
accept="image/jpeg,image/png,image/gif"
className="hidden"
onChange={(e) => {
if (e.target.files[0]) handleImageUpload(pet.customerPetId, e.target.files[0]);
e.target.value = "";
}}
/>
📷
</label>
</div>
<div className="p-3 flex flex-col gap-1">
<span className="font-semibold text-[#222] text-[0.95rem]">{pet.petName}</span>
<span className="text-[0.82rem] text-[#666]">{pet.species}</span>
{pet.breed && <span className="text-[0.82rem] text-[#666]">{pet.breed}</span>}
{pet.petAge != null && <span className="text-[0.82rem] text-[#666]">Age: {pet.petAge === 0 ? "< 1 yr" : `${pet.petAge} yr${pet.petAge !== 1 ? "s" : ""}`}</span>}
</div>
<div className="flex gap-2 px-3 pb-3">
<button type="button" className="px-3 py-1.5 border border-[#ddd] rounded-lg bg-white text-[#333] text-[0.82rem] cursor-pointer hover:border-[#e68672] transition-colors" onClick={() => openEditForm(pet)}>Edit</button>
<button type="button" className="px-3 py-1.5 border border-[#f5c6c6] rounded-lg bg-[#fff0f0] text-[#c0392b] text-[0.82rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-[#333] m-0">Order History</h2>
</div>
{loadingOrders ? (
<p className="text-[#666] text-center py-4">Loading orders...</p>
) : orders.length === 0 ? (
<p className="text-center text-[#888] py-8">No orders yet.</p>
) : (
<div className="flex flex-col gap-4">
{orders.map((order) => (
<div key={order.saleId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="text-[0.9rem] font-semibold text-[#444]">
{new Date(order.saleDate).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" })}
</span>
<span className="text-[1rem] font-bold text-[#e68672]">${Number(order.totalAmount).toFixed(2)}</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#888] mb-2">
<span>{order.storeName}</span>
{order.paymentMethod && <span>{order.paymentMethod}</span>}
</div>
{order.items?.length > 0 && (
<ul className="list-none m-0 p-0 flex flex-col gap-1 border-t border-[#eee] pt-2 mt-2">
{order.items.map((item) => (
<li key={item.saleItemId} className="flex justify-between text-[0.85rem] text-[#555]">
<span>{item.productName} × {item.quantity}</span>
<span className="font-semibold">${(Number(item.unitPrice) * item.quantity).toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
)}
</main>
);
}