Files
group-2-threaded-project-pe…/web/app/profile/page.js
augmentedpotato 79c42574f6 Styling refactor
2026-04-18 16:22:38 -06:00

651 lines
25 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.
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
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] 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";
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);
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]);
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]);
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]);
function handleLogout() {
logout();
router.push("/");
}
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);
}
}
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);
}
}
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);
}
}
function openAddForm() {
setEditingPet(null);
setPetName("");
setSpecies("");
setBreed("");
setPetAge("1");
setPetError(null);
setShowForm(true);
}
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);
}
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);
}
}
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);
}
}
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">{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} 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>
);
}