Merge pull request #326 from RecentRunner/web-refactor
web styling refactor
This commit is contained in:
@@ -1,23 +1,23 @@
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="info-page">
|
||||
<section className="info-hero">
|
||||
<h1 className="info-title">About Leon's Pet Store</h1>
|
||||
<p className="info-subtitle">Pet care, adoption support, grooming, and everyday essentials in one place.</p>
|
||||
<div className="title-decoration"></div>
|
||||
<main className="bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<section className="text-center px-8 pt-10 pb-6">
|
||||
<h1 className="text-[1.6rem] font-bold text-[#333] mb-2 uppercase tracking-[0.08em]">About Leon's Pet Store</h1>
|
||||
<p className="text-base text-[#888] mb-4 max-w-[520px] mx-auto leading-relaxed">Pet care, adoption support, grooming, and everyday essentials in one place.</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
<section className="info-content">
|
||||
<div className="info-card">
|
||||
<h2>What We Do</h2>
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-6 grid grid-cols-3 gap-6 max-[768px]:grid-cols-1">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h2 className="mt-0 mb-4 text-[#222]">What We Do</h2>
|
||||
<p>
|
||||
Leon's Pet Store connects families with adoptable pets, helpful services, and quality products for day-to-day pet care.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Our Focus</h2>
|
||||
<ul className="info-list">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h2 className="mt-0 mb-4 text-[#222]">Our Focus</h2>
|
||||
<ul className="m-0 pl-5 grid gap-2 list-disc">
|
||||
<li>Support responsible pet adoption</li>
|
||||
<li>Provide grooming and care services</li>
|
||||
<li>Offer reliable pet supplies and essentials</li>
|
||||
@@ -25,8 +25,8 @@ export default function AboutPage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Visit the Store</h2>
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h2 className="mt-0 mb-4 text-[#222]">Visit the Store</h2>
|
||||
<p>
|
||||
Browse adoptable pets, schedule appointments, shop products, or contact the team for help finding the right fit for a pet and household.
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function PetDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
fetch(`${API_BASE}/api/v1/pets/${id}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} – ${res.statusText}`);
|
||||
@@ -27,12 +26,12 @@ export default function PetDetailPage() {
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<main className="pet-detail-page">
|
||||
<div className="pet-detail-container">
|
||||
<Link href="/adopt" className="pet-detail-back">← Back to Pets</Link>
|
||||
<main className="min-h-screen py-12 px-8 pb-20">
|
||||
<div className="max-w-[860px] mx-auto">
|
||||
<Link href="/adopt" className="inline-block mb-8 text-[#e68672] no-underline text-base font-semibold transition-colors hover:text-[#d4705e]">← Back to Pets</Link>
|
||||
|
||||
{loading && <p className="adopt-status-msg">Loading pet details...</p>}
|
||||
{error && <p className="adopt-status-msg adopt-error">{error}</p>}
|
||||
{loading && <p className="text-center text-[#666] text-[1.1rem] py-12">Loading pet details...</p>}
|
||||
{error && <p className="text-center text-[#c0392b] text-[1.1rem] py-12">{error}</p>}
|
||||
|
||||
{!loading && !error && pet && (
|
||||
<PetProfile
|
||||
|
||||
@@ -8,28 +8,37 @@ import { useCart } from "@/context/CartContext";
|
||||
const API_BASE = "";
|
||||
const PAGE_SIZE = 10000;
|
||||
|
||||
const inputCls = "px-4 py-[0.6rem] border-2 border-[#ddd] rounded-md text-base outline-none transition-colors focus:border-[#e68672]";
|
||||
const btnPrimaryCls = "px-[1.4rem] py-[0.6rem] bg-[#e68672] text-white border-none rounded-md text-base cursor-pointer transition-colors hover:bg-[#d4705e]";
|
||||
const btnOutlineCls = "px-4 py-[0.6rem] bg-transparent text-[#666] border-2 border-[#ddd] rounded-md text-base cursor-pointer transition-all hover:border-[#aaa] hover:text-[#333]";
|
||||
|
||||
function PaginationBtn({ children, active, ...props }) {
|
||||
return (
|
||||
<button
|
||||
className={`border-none rounded-lg px-[0.9rem] py-2 text-[0.9rem] font-semibold cursor-pointer transition-colors ${active ? "bg-[#e68672] text-white hover:bg-[#d4705e]" : "bg-[#e8e8e8] text-[#333] hover:bg-[#d0d0d0]"} disabled:bg-[#f0f0f0] disabled:text-[#aaa] disabled:cursor-not-allowed`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdoptPage() {
|
||||
const { selectedStoreId } = useCart();
|
||||
|
||||
// pets = everything returned by the server (filtered by species + store + text query)
|
||||
const [pets, setPets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [selectedSpecies, setSelectedSpecies] = useState("");
|
||||
const [selectedBreed, setSelectedBreed] = useState("");
|
||||
|
||||
// Species options come from a dedicated fetch (only store-filtered, no species filter)
|
||||
const [speciesOptions, setSpeciesOptions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSpecies("");
|
||||
const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) });
|
||||
if (selectedStoreId) params.set("storeId", String(selectedStoreId));
|
||||
|
||||
fetch(`${API_BASE}/api/v1/pets?${params}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
@@ -42,23 +51,16 @@ export default function AdoptPage() {
|
||||
.catch(() => setSpeciesOptions([]));
|
||||
}, [selectedStoreId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBreed("");
|
||||
}, [selectedSpecies]);
|
||||
useEffect(() => { setSelectedBreed(""); }, [selectedSpecies]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchAllPages((page) => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(PAGE_SIZE),
|
||||
sort: "id,asc",
|
||||
});
|
||||
const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE), sort: "id,asc" });
|
||||
if (query) params.set("q", query);
|
||||
if (selectedSpecies) params.set("species", selectedSpecies);
|
||||
if (selectedStoreId) params.set("storeId", String(selectedStoreId));
|
||||
|
||||
return `${API_BASE}/api/v1/pets?${params}`;
|
||||
})
|
||||
.then(setPets)
|
||||
@@ -67,171 +69,123 @@ export default function AdoptPage() {
|
||||
}, [query, selectedSpecies, selectedStoreId]);
|
||||
|
||||
const breedOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" })
|
||||
),
|
||||
() => [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" })
|
||||
),
|
||||
[pets]
|
||||
);
|
||||
|
||||
|
||||
const ITEMS_PER_PAGE = 24;
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const filteredPets = useMemo(
|
||||
() => (selectedBreed ? pets.filter((p) => p.petBreed === selectedBreed) : pets),
|
||||
[pets, selectedBreed]
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredPets.length / ITEMS_PER_PAGE);
|
||||
const displayedPets = filteredPets.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
|
||||
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
setCurrentPage(0);
|
||||
setQuery(search.trim());
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
setSearch("");
|
||||
setQuery("");
|
||||
setSelectedSpecies("");
|
||||
setSelectedBreed("");
|
||||
setCurrentPage(0);
|
||||
}
|
||||
|
||||
function handleSearch(e) { e.preventDefault(); setCurrentPage(0); setQuery(search.trim()); }
|
||||
function handleClearFilters() { setSearch(""); setQuery(""); setSelectedSpecies(""); setSelectedBreed(""); setCurrentPage(0); }
|
||||
const hasActiveFilters = query || selectedSpecies || selectedBreed;
|
||||
|
||||
return (
|
||||
<main className="adopt-page">
|
||||
<section className="adopt-hero">
|
||||
<h1 className="adopt-hero-title">Find Your Perfect Companion</h1>
|
||||
<p className="adopt-hero-subtitle">Give a loving pet their forever home</p>
|
||||
<div className="title-decoration"></div>
|
||||
<main className="min-h-screen">
|
||||
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[768px]:text-3xl max-[480px]:text-[1.6rem]">Find Your Perfect Companion</h1>
|
||||
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.2rem]">Give a loving pet their forever home</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
<section className="adopt-controls">
|
||||
<div className="adopt-filters-row">
|
||||
<div className="adopt-filter-group">
|
||||
<label className="adopt-filter-label" htmlFor="species-filter">Species</label>
|
||||
<section className="max-w-[1200px] mx-auto mb-6 px-8">
|
||||
<div className="flex items-end flex-wrap gap-4 mb-4 max-[600px]:flex-col max-[600px]:items-stretch">
|
||||
<div className="flex flex-col gap-[0.3rem] min-w-[160px] max-[600px]:min-w-0 max-[600px]:w-full">
|
||||
<label className="text-[0.8rem] font-semibold text-[#555] uppercase tracking-[0.04em]" htmlFor="species-filter">Species</label>
|
||||
<select
|
||||
id="species-filter"
|
||||
className="adopt-filter-select"
|
||||
className={`custom-select ${inputCls} bg-white text-[#333] cursor-pointer pr-8`}
|
||||
value={selectedSpecies}
|
||||
onChange={(e) => { setSelectedSpecies(e.target.value); setCurrentPage(0); }}
|
||||
>
|
||||
<option value="">All Species</option>
|
||||
{speciesOptions.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
{speciesOptions.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="adopt-filter-group">
|
||||
<label className="adopt-filter-label" htmlFor="breed-filter">Breed</label>
|
||||
<div className="flex flex-col gap-[0.3rem] min-w-[160px] max-[600px]:min-w-0 max-[600px]:w-full">
|
||||
<label className="text-[0.8rem] font-semibold text-[#555] uppercase tracking-[0.04em]" htmlFor="breed-filter">Breed</label>
|
||||
<select
|
||||
id="breed-filter"
|
||||
className="adopt-filter-select"
|
||||
className={`custom-select ${inputCls} bg-white text-[#333] cursor-pointer pr-8 disabled:bg-[#f5f5f5] disabled:text-[#aaa] disabled:cursor-not-allowed disabled:border-[#e0e0e0]`}
|
||||
value={selectedBreed}
|
||||
onChange={(e) => { setSelectedBreed(e.target.value); setCurrentPage(0); }}
|
||||
disabled={!selectedSpecies}
|
||||
>
|
||||
<option value="">{!selectedSpecies ? "Select a species first" : "All Breeds"}</option>
|
||||
{breedOptions.map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
{breedOptions.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="adopt-controls-row">
|
||||
<form className="adopt-search-form" onSubmit={handleSearch}>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4 max-[600px]:flex-col max-[600px]:items-stretch">
|
||||
<form className="flex gap-3 items-center max-[600px]:flex-col max-[600px]:items-stretch" onSubmit={handleSearch}>
|
||||
<input
|
||||
className="adopt-search-input"
|
||||
className={`${inputCls} flex-1 max-w-[400px] font-[inherit] max-[600px]:max-w-full`}
|
||||
type="text"
|
||||
placeholder="Search by name, species, or breed..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<button className="adopt-search-btn" type="submit">Search</button>
|
||||
<button className={`${btnPrimaryCls} font-[inherit] max-[600px]:w-full`} type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button className="adopt-clear-btn" type="button" onClick={handleClearFilters}>
|
||||
<button className={`${btnOutlineCls} font-[inherit] max-[600px]:w-full`} type="button" onClick={handleClearFilters}>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="adopt-grid-section">
|
||||
{loading && <p className="adopt-status-msg">Loading pets...</p>}
|
||||
|
||||
{error && (
|
||||
<p className="adopt-status-msg">Unable to load pets, please try again later.</p>
|
||||
)}
|
||||
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-16">
|
||||
{loading && <p className="text-center text-[#666] text-[1.1rem] py-12">Loading pets...</p>}
|
||||
{error && <p className="text-center text-[#666] text-[1.1rem] py-12">Unable to load pets, please try again later.</p>}
|
||||
{!loading && !error && displayedPets.length === 0 && (
|
||||
<p className="adopt-status-msg">No pets found matching your filters.</p>
|
||||
<p className="text-center text-[#666] text-[1.1rem] py-12">No pets found matching your filters.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayedPets.length > 0 && (
|
||||
<div className="adopt-grid">
|
||||
<div className="grid grid-cols-4 gap-7 max-[1024px]:grid-cols-3 max-[480px]:grid-cols-2 max-[360px]:grid-cols-1">
|
||||
{displayedPets.map((pet) => (
|
||||
<PetCard
|
||||
key={pet.petId}
|
||||
petId={pet.petId}
|
||||
petName={pet.petName}
|
||||
petSpecies={pet.petSpecies}
|
||||
petStatus={pet.petStatus}
|
||||
imageUrl={pet.imageUrl}
|
||||
/>
|
||||
<PetCard key={pet.petId} petId={pet.petId} petName={pet.petName} petSpecies={pet.petSpecies} petStatus={pet.petStatus} imageUrl={pet.imageUrl} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalPages > 1 && (
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => { setCurrentPage((p) => Math.max(0, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-[0.4rem] py-6 px-4 flex-wrap mt-6">
|
||||
<PaginationBtn onClick={() => { setCurrentPage((p) => Math.max(0, p - 1)); window.scrollTo(0, 0); }} disabled={currentPage === 0}>
|
||||
← Prev
|
||||
</button>
|
||||
</PaginationBtn>
|
||||
{(() => {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
const left = Math.max(0, currentPage - delta);
|
||||
const right = Math.min(totalPages - 1, currentPage + delta);
|
||||
if (left > 0) {
|
||||
pages.push(0);
|
||||
if (left > 1) pages.push("...");
|
||||
}
|
||||
if (left > 0) { pages.push(0); if (left > 1) pages.push("..."); }
|
||||
for (let i = left; i <= right; i++) pages.push(i);
|
||||
if (right < totalPages - 1) {
|
||||
if (right < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages - 1);
|
||||
}
|
||||
if (right < totalPages - 1) { if (right < totalPages - 2) pages.push("..."); pages.push(totalPages - 1); }
|
||||
return pages.map((p, i) =>
|
||||
p === "..." ? (
|
||||
<span key={`ellipsis-${i}`} className="pagination-ellipsis">…</span>
|
||||
<span key={`ellipsis-${i}`} className="px-1 py-2 text-[#888] font-semibold">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pagination-btn${p === currentPage ? " pagination-btn--active" : ""}`}
|
||||
onClick={() => { setCurrentPage(p); window.scrollTo(0, 0); }}
|
||||
>
|
||||
<PaginationBtn key={p} active={p === currentPage} onClick={() => { setCurrentPage(p); window.scrollTo(0, 0); }}>
|
||||
{p + 1}
|
||||
</button>
|
||||
</PaginationBtn>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => { setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
<PaginationBtn onClick={() => { setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); window.scrollTo(0, 0); }} disabled={currentPage === totalPages - 1}>
|
||||
Next →
|
||||
</button>
|
||||
</PaginationBtn>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -39,8 +39,7 @@ function getAvailableServices(services, species) {
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
function DatePicker({ value, minDate, onChange }) {
|
||||
const today = new Date();
|
||||
@@ -53,24 +52,13 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
const [viewMonth, setViewMonth] = useState(parsed ? parsed.getMonth() : min.getMonth());
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
}
|
||||
|
||||
else {
|
||||
setViewMonth((m) => m - 1);
|
||||
}
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
|
||||
else { setViewMonth((m) => m - 1); }
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0); setViewYear((y) => y + 1);
|
||||
}
|
||||
|
||||
else {
|
||||
setViewMonth((m) => m + 1);
|
||||
}
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
|
||||
else { setViewMonth((m) => m + 1); }
|
||||
}
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||
@@ -82,11 +70,8 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
|
||||
function selectDay(day) {
|
||||
const d = new Date(viewYear, viewMonth, day);
|
||||
if (d < min) {
|
||||
return;
|
||||
}
|
||||
if (d < min) return;
|
||||
const iso = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
onChange(iso);
|
||||
}
|
||||
|
||||
@@ -107,94 +92,16 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
cells.push({ key: `day-${viewYear}-${viewMonth}-${String(d)}`, day: d });
|
||||
}
|
||||
|
||||
const s = {
|
||||
widget: {
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
background: "white",
|
||||
userSelect: "none",
|
||||
fontFamily: "inherit",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "orange",
|
||||
padding: "0.55rem 0.75rem",
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
},
|
||||
nav: {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "white",
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: 1,
|
||||
cursor: "pointer",
|
||||
padding: "0 0.4rem",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "3px",
|
||||
padding: "0.6rem",
|
||||
},
|
||||
dayName: {
|
||||
textAlign: "center",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
color: "#aaa",
|
||||
padding: "0.25rem 0",
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
dayBase: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
aspectRatio: "1 / 1",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
background: "none",
|
||||
fontSize: "0.875rem",
|
||||
cursor: "pointer",
|
||||
color: "#333",
|
||||
fontFamily: "inherit",
|
||||
padding: 0,
|
||||
width: "100%",
|
||||
},
|
||||
daySelected: {
|
||||
background: "orange",
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
},
|
||||
dayDisabled: {
|
||||
color: "#ccc",
|
||||
cursor: "default",
|
||||
},
|
||||
selectedLabel: {
|
||||
textAlign: "center",
|
||||
fontSize: "0.82rem",
|
||||
color: "#666",
|
||||
padding: "0.35rem 0.5rem 0.5rem",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.widget}>
|
||||
<div style={s.header}>
|
||||
<button type="button" style={s.nav} onClick={prevMonth} disabled={isPrevDisabled} aria-label="Previous month">‹</button>
|
||||
<span style={s.monthLabel}>{MONTHS[viewMonth]} {viewYear}</span>
|
||||
<button type="button" style={s.nav} onClick={nextMonth} aria-label="Next month">›</button>
|
||||
<div className="border border-[#ddd] rounded-[10px] overflow-hidden bg-white select-none">
|
||||
<div className="flex items-center justify-between bg-orange-500 px-3 py-[0.55rem]">
|
||||
<button type="button" className="bg-transparent border-none text-white text-2xl leading-none cursor-pointer px-[0.4rem] rounded hover:bg-white/20 disabled:opacity-40" onClick={prevMonth} disabled={isPrevDisabled} aria-label="Previous month">‹</button>
|
||||
<span className="text-[0.95rem] font-bold text-white">{MONTHS[viewMonth]} {viewYear}</span>
|
||||
<button type="button" className="bg-transparent border-none text-white text-2xl leading-none cursor-pointer px-[0.4rem] rounded hover:bg-white/20" onClick={nextMonth} aria-label="Next month">›</button>
|
||||
</div>
|
||||
<div style={s.grid}>
|
||||
<div className="grid grid-cols-7 gap-[3px] p-[0.6rem]">
|
||||
{DAYS.map((d) => (
|
||||
<span key={d} style={s.dayName}>{d}</span>
|
||||
<span key={d} className="text-center text-[0.7rem] font-bold text-[#aaa] py-1 uppercase">{d}</span>
|
||||
))}
|
||||
{cells.map(({ key, day }) =>
|
||||
day === null ? (
|
||||
@@ -203,11 +110,8 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
style={{
|
||||
...s.dayBase,
|
||||
...(isSelected(day) ? s.daySelected : {}),
|
||||
...(isDisabled(day) ? s.dayDisabled : {}),
|
||||
}}
|
||||
className={`flex items-center justify-center aspect-square border-none rounded-md text-[0.875rem] cursor-pointer w-full p-0 transition-colors
|
||||
${isSelected(day) ? "bg-orange-500 text-white font-bold" : isDisabled(day) ? "text-[#ccc] cursor-default bg-transparent" : "bg-transparent text-[#333] hover:bg-orange-100"}`}
|
||||
onClick={() => selectDay(day)}
|
||||
disabled={isDisabled(day)}
|
||||
aria-pressed={isSelected(day)}
|
||||
@@ -218,7 +122,7 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
)}
|
||||
</div>
|
||||
{parsed && (
|
||||
<div style={s.selectedLabel}>
|
||||
<div className="text-center text-[0.82rem] text-[#666] px-2 pb-2 pt-1 border-t border-[#f0f0f0]">
|
||||
Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()}
|
||||
</div>
|
||||
)}
|
||||
@@ -226,6 +130,13 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function AddPetModal({ token, onClose, onAdded }) {
|
||||
const [petName, setPetName] = useState("");
|
||||
const [species, setSpecies] = useState("");
|
||||
@@ -267,56 +178,38 @@ function AddPetModal({ token, onClose, onAdded }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appt-modal-overlay" onClick={onClose}>
|
||||
<div className="appt-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="profile-pet-form-title">Add a New Pet</h3>
|
||||
{petError && <div className="appt-error">{petError}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="appt-label">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-[480px] flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-[1.1rem] font-bold text-[#333] m-0">Add a New Pet</h3>
|
||||
{petError && <div className={errorCls}>{petError}</div>}
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<label className={labelCls}>
|
||||
Name
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={petName}
|
||||
onChange={(e) => setPetName(e.target.value)}
|
||||
required
|
||||
maxLength={50}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={petName} onChange={(e) => setPetName(e.target.value)} required maxLength={50} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Species
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
>
|
||||
<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="appt-label">
|
||||
<label className={labelCls}>
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<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>
|
||||
<div className="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className={submitBtnCls} disabled={submitting}>
|
||||
{submitting ? "Saving..." : "Add Pet"}
|
||||
</button>
|
||||
<button type="button" className="profile-pet-cancel-btn" onClick={onClose}>
|
||||
<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={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -332,7 +225,6 @@ function AppointmentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const preselectedPetId = searchParams.get("petId");
|
||||
|
||||
// Adoption mode — set when arriving from a pet detail page
|
||||
const adoptionMode = searchParams.get("adoptionMode") === "true";
|
||||
const adoptionPetId = searchParams.get("petId");
|
||||
const adoptionPetName = searchParams.get("petName") || "";
|
||||
@@ -345,7 +237,6 @@ function AppointmentsPage() {
|
||||
const errorRef = useRef(null);
|
||||
const historyRef = useRef(null);
|
||||
|
||||
// Adoption-mode URL verification
|
||||
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
|
||||
const [adoptionVerifyError, setAdoptionVerifyError] = useState(null);
|
||||
const [adoptionVerifyLoading, setAdoptionVerifyLoading] = useState(adoptionMode);
|
||||
@@ -388,17 +279,15 @@ function AppointmentsPage() {
|
||||
const [showPastAppts, setShowPastAppts] = useState(false);
|
||||
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
|
||||
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments";
|
||||
router.push(`/login?next=${encodeURIComponent(target)}`);
|
||||
}
|
||||
|
||||
}, [authLoading, user, router, preselectedPetId]);
|
||||
|
||||
// Verify the pet from the URL is real, available, and at the stated store
|
||||
useEffect(() => {
|
||||
if (!adoptionMode || !adoptionPetId) return;
|
||||
setAdoptionVerifyLoading(true);
|
||||
@@ -431,9 +320,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, canBookAppointments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
|
||||
fetch(`${API_BASE}/api/v1/dropdowns/stores`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -459,10 +346,8 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
if (didPreselectRef.current) return;
|
||||
|
||||
if (adoptionMode) {
|
||||
// Need both the store (so employees load) and a serviceId (so availability slots load)
|
||||
if (adoptionStoreId && services.length > 0) {
|
||||
setStoreId(adoptionStoreId);
|
||||
// Prefer a service named "adopt", fall back to the first available service
|
||||
const adoptionSvc =
|
||||
services.find((s) => s.serviceName.toLowerCase().includes("adopt")) ||
|
||||
services[0];
|
||||
@@ -474,14 +359,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adoptionSvc = services.find((s) =>
|
||||
s.serviceName.toLowerCase().includes("adopt")
|
||||
);
|
||||
if (!preselectedPetId || services.length === 0 || allPets.length === 0) return;
|
||||
|
||||
const adoptionSvc = services.find((s) => s.serviceName.toLowerCase().includes("adopt"));
|
||||
if (adoptionSvc) {
|
||||
setServiceId(String(adoptionSvc.serviceId));
|
||||
}
|
||||
@@ -574,11 +454,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, storeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!employees.length) {
|
||||
setEmployeeId("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employees.length) { setEmployeeId(""); return; }
|
||||
const currentExists = employees.some((employee) => String(employee.id) === String(employeeId));
|
||||
if (!currentExists) {
|
||||
setEmployeeId(String(employees[0].id));
|
||||
@@ -589,7 +465,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
if (!storeId || !serviceId || !appointmentDate) {
|
||||
setAvailableSlots([]);
|
||||
setAppointmentTime("");
|
||||
|
||||
return;
|
||||
}
|
||||
setLoadingSlots(true);
|
||||
@@ -597,10 +472,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
const params = new URLSearchParams({ storeId, serviceId, date: appointmentDate });
|
||||
fetch(`${API_BASE}/api/v1/appointments/availability?${params}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to check availability");
|
||||
}
|
||||
|
||||
if (!r.ok) throw new Error("Failed to check availability");
|
||||
return r.json();
|
||||
})
|
||||
.then(setAvailableSlots)
|
||||
@@ -640,13 +512,11 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
const hour = parseInt(h, 10);
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const display = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
|
||||
return `${display}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function getMinDate() {
|
||||
const d = new Date();
|
||||
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
@@ -686,7 +556,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
try {
|
||||
if (adoptionMode) {
|
||||
// Submit an adoption request directly to the adoption table
|
||||
const body = {
|
||||
petId: Number(adoptionPetId),
|
||||
employeeId: employeeId ? Number(employeeId) : undefined,
|
||||
@@ -740,7 +609,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -753,22 +621,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
setAvailableSlots([]);
|
||||
loadAppointments();
|
||||
setTimeout(() => historyRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 300);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
|
||||
return (
|
||||
<main className="appt-page">
|
||||
<p className="appt-loading">Loading...</p>
|
||||
<main className="min-h-screen">
|
||||
<p className="text-center text-[#666] py-12">Loading...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -776,7 +643,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<main className="appt-page">
|
||||
<main className="min-h-screen">
|
||||
{showAddPetModal && (
|
||||
<AddPetModal
|
||||
token={token}
|
||||
@@ -785,63 +652,68 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="appt-hero">
|
||||
<h1 className="appt-hero-title">{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}</h1>
|
||||
<p className="appt-hero-subtitle">{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}</p>
|
||||
<div className="title-decoration"></div>
|
||||
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[768px]:text-3xl max-[480px]:text-[1.6rem]">
|
||||
{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}
|
||||
</h1>
|
||||
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.2rem]">
|
||||
{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}
|
||||
</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
<section className="appt-content">
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-16 flex gap-8 items-start max-[900px]:flex-col">
|
||||
{canBookAppointments ? (
|
||||
<form className="appt-form" onSubmit={handleSubmit}>
|
||||
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||
<form className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8 flex-1 flex flex-col gap-4 max-[900px]:w-full" onSubmit={handleSubmit}>
|
||||
<h2 className="text-xl font-bold text-[#333] m-0">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||
|
||||
{error && <div className="appt-error" ref={errorRef}>{error}</div>}
|
||||
{error && <div className={errorCls} ref={errorRef}>{error}</div>}
|
||||
|
||||
{adoptionMode && adoptionVerifyLoading && (
|
||||
<p className="appt-loading">Verifying pet details…</p>
|
||||
<p className="text-[#666] text-center py-2">Verifying pet details…</p>
|
||||
)}
|
||||
{adoptionMode && adoptionVerifyError && (
|
||||
<div className="appt-error">{adoptionVerifyError}</div>
|
||||
<div className={errorCls}>{adoptionVerifyError}</div>
|
||||
)}
|
||||
|
||||
{(!adoptionMode || adoptionVerified) && (<>
|
||||
|
||||
{/* ADOPTION MODE: locked pet + store */}
|
||||
{adoptionMode && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Pet
|
||||
<div className="appt-locked-field">
|
||||
<div className="px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg bg-[#f5f5f5] text-[#555]">
|
||||
{[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* STEP 1 (non-adoption): select a pet first */}
|
||||
{!adoptionMode && (
|
||||
<div className="appt-label">
|
||||
<div className={labelCls}>
|
||||
<span>Select Your Pet</span>
|
||||
{eligiblePets.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
<p className="text-[#888] text-[0.9rem] m-0">
|
||||
You have no adopted pets available.{" "}
|
||||
<Link href="/profile">Add a pet on your profile page.</Link>
|
||||
<Link href="/profile" className="text-[#e68672] hover:underline">Add a pet on your profile page.</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eligiblePets.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-full border-2 text-[0.85rem] font-semibold transition-all
|
||||
${selectedPetIds.includes(p.customerPetId)
|
||||
? "border-[#e68672] bg-[#fff4f2] text-[#e68672]"
|
||||
: "border-[#ddd] bg-white text-[#333] hover:border-[#e68672]"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="customerPet"
|
||||
checked={selectedPetIds.includes(p.customerPetId)}
|
||||
onChange={() => handlePetSelect(p.customerPetId)}
|
||||
className="appt-pet-checkbox"
|
||||
className="hidden"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
<span className="text-[#888] font-normal">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -849,20 +721,14 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining fields — shown after pet selected (or always in adoption mode) */}
|
||||
{(adoptionMode || selectedPetIds.length > 0) && (<>
|
||||
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Store Location
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
|
||||
<div className="px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg bg-[#f5f5f5] text-[#555]">{adoptionStoreName || "Pet's store"}</div>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<select className={selectCls} value={storeId} onChange={(e) => setStoreId(e.target.value)} required>
|
||||
<option value="">Select a store...</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
@@ -872,19 +738,14 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</label>
|
||||
|
||||
{!adoptionMode && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Service
|
||||
{availableServices.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
<p className="text-[#888] text-[0.9rem] m-0">
|
||||
No services are available for {selectedPet?.species || "this pet"}.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
onChange={(e) => handleServiceChange(e.target.value)}
|
||||
required
|
||||
>
|
||||
<select className={selectCls} value={serviceId} onChange={(e) => handleServiceChange(e.target.value)} required>
|
||||
<option value="">Select a service...</option>
|
||||
{availableServices.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
@@ -897,13 +758,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
{employees.length > 0 && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Employee
|
||||
<select
|
||||
className="appt-select"
|
||||
value={employeeId}
|
||||
onChange={(e) => setEmployeeId(e.target.value)}
|
||||
>
|
||||
<select className={selectCls} value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>{employee.label}</option>
|
||||
))}
|
||||
@@ -912,12 +769,12 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
{!adoptionMode && selectedService && (
|
||||
<div className="appt-service-info">
|
||||
<p>{selectedService.serviceDesc}</p>
|
||||
<div className="bg-[#f9f9f9] rounded-lg px-4 py-3 text-[0.85rem] text-[#555] border border-[#eee]">
|
||||
<p className="m-0">{selectedService.serviceDesc}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="appt-label">
|
||||
<div className={labelCls}>
|
||||
Date
|
||||
<DatePicker
|
||||
value={appointmentDate}
|
||||
@@ -927,19 +784,22 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
|
||||
{!adoptionMode && storeId && serviceId && appointmentDate && (
|
||||
<div className="appt-label">
|
||||
<div className={labelCls}>
|
||||
<span>Available Time Slots</span>
|
||||
{loadingSlots ? (
|
||||
<p className="appt-slots-loading">Checking availability...</p>
|
||||
<p className="text-[#888] text-[0.9rem] m-0">Checking availability...</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p className="appt-no-slots">No available slots for this date. Please try another date.</p>
|
||||
<p className="text-[#888] text-[0.9rem] m-0">No available slots for this date. Please try another date.</p>
|
||||
) : (
|
||||
<div className="appt-slots-grid">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSlots.map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
className={`appt-slot-btn ${appointmentTime === slot ? "appt-slot-btn--selected" : ""}`}
|
||||
className={`px-3 py-2 rounded-lg border-2 text-[0.85rem] cursor-pointer transition-all
|
||||
${appointmentTime === slot
|
||||
? "border-[#e68672] bg-[#e68672] text-white"
|
||||
: "border-[#ddd] bg-white text-[#333] hover:border-[#e68672]"}`}
|
||||
onClick={() => setAppointmentTime(slot)}
|
||||
>
|
||||
{formatTime(slot)}
|
||||
@@ -952,24 +812,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
</>)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="appt-submit-btn"
|
||||
disabled={!formValid || submitting}
|
||||
>
|
||||
<button type="submit" className={submitBtnCls} disabled={!formValid || submitting}>
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
|
||||
</button>
|
||||
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
{success && <div className={successCls}>{success}</div>}
|
||||
|
||||
</>)}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="appt-history" ref={historyRef}>
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{/* History panel */}
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8 flex-1 flex flex-col gap-4 max-[900px]:w-full" ref={historyRef}>
|
||||
<h2 className="text-xl font-bold text-[#333] m-0">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{loadingAppointments ? (
|
||||
<p className="appt-loading">Loading appointments...</p>
|
||||
<p className="text-[#666] text-center py-4">Loading appointments...</p>
|
||||
) : (() => {
|
||||
const activeAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() === "booked");
|
||||
const pastAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() !== "booked");
|
||||
@@ -980,35 +837,35 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
className="px-4 py-2 border border-[#ddd] rounded-lg text-[0.9rem] outline-none w-full focus:border-[#e68672] transition-colors"
|
||||
type="text"
|
||||
placeholder="Search appointments…"
|
||||
value={apptSearch}
|
||||
onChange={(e) => setApptSearch(e.target.value)}
|
||||
/>
|
||||
{filteredActive.length === 0 ? (
|
||||
<p className="appt-empty">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
|
||||
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
<div className="flex flex-col gap-3">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="appt-card-pets">Pet: {a.petName}</div>
|
||||
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
|
||||
)}
|
||||
<div className="appt-card-actions">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
@@ -1020,26 +877,26 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
{pastAppts.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAppts((v) => !v)}>
|
||||
<div className="mt-2">
|
||||
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAppts((v) => !v)}>
|
||||
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
|
||||
</button>
|
||||
{showPastAppts && (
|
||||
<div className="appt-list appt-list--past">
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAppts.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="appt-card-pets">Pet: {a.petName}</div>
|
||||
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1051,9 +908,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
);
|
||||
})()}
|
||||
|
||||
<h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
|
||||
<h2 className="text-xl font-bold text-[#333] m-0 mt-4">{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
|
||||
{loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
<p className="text-[#666] text-center py-4">Loading adoptions...</p>
|
||||
) : (() => {
|
||||
const activeAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() === "pending");
|
||||
const pastAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() !== "pending");
|
||||
@@ -1064,32 +921,32 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
className="px-4 py-2 border border-[#ddd] rounded-lg text-[0.9rem] outline-none w-full focus:border-[#e68672] transition-colors"
|
||||
type="text"
|
||||
placeholder="Search adoptions…"
|
||||
value={adoptionSearch}
|
||||
onChange={(e) => setAdoptionSearch(e.target.value)}
|
||||
/>
|
||||
{filteredActive.length === 0 ? (
|
||||
<p className="appt-empty">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
|
||||
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
<div className="flex flex-col gap-3">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
<div className="appt-card-actions">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
@@ -1101,21 +958,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
{pastAdoptions.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAdoptions((v) => !v)}>
|
||||
<div className="mt-2">
|
||||
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAdoptions((v) => !v)}>
|
||||
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
|
||||
</button>
|
||||
{showPastAdoptions && (
|
||||
<div className="appt-list appt-list--past">
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAdoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
|
||||
@@ -52,28 +52,26 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cart-payment-form">
|
||||
<h3 className="cart-payment-title">Payment Details</h3>
|
||||
<p className="cart-payment-total">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-bold text-[#222] m-0">Payment Details</h3>
|
||||
<p className="text-[0.95rem] text-[#555] m-0">
|
||||
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
|
||||
</p>
|
||||
<div className="cart-demo-banner">
|
||||
<strong>Demo mode</strong> — no real charge. Use test card:
|
||||
<span className="cart-demo-card">4242 4242 4242 4242</span>
|
||||
· any future date · any 3-digit CVC
|
||||
<div className="bg-[#fffbeb] border border-[#fde68a] rounded-lg px-4 py-3 text-[0.82rem] text-[#854d0e] flex flex-col gap-1">
|
||||
<div><strong>Demo mode</strong> — no real charge. Use test card: <span className="font-mono font-bold">4242 4242 4242 4242</span> · any future date · any 3-digit CVC</div>
|
||||
</div>
|
||||
<PaymentElement />
|
||||
{payError && <p className="cart-error-msg">{payError}</p>}
|
||||
<div className="cart-payment-actions">
|
||||
{payError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{payError}</p>}
|
||||
<div className="flex gap-3 mt-2">
|
||||
<button
|
||||
className="cart-pay-btn"
|
||||
className="flex-1 py-3 bg-[#e68672] text-white border-none rounded-lg font-bold cursor-pointer hover:bg-[#d4705e] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={handlePay}
|
||||
disabled={paying || !stripe}
|
||||
>
|
||||
{paying ? "Processing…" : `Pay $${parseFloat(totalAmount).toFixed(2)}`}
|
||||
</button>
|
||||
<button className="cart-cancel-btn" type="button" onClick={onCancel}>
|
||||
<button className="px-4 py-3 border border-[#ddd] rounded-lg bg-white text-[#555] cursor-pointer hover:border-[#aaa] transition-colors" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -128,12 +126,9 @@ export default function CartPage() {
|
||||
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
|
||||
setLocalQuantities(map);
|
||||
}
|
||||
// Sync optimistic state back to server truth whenever cart updates
|
||||
setOptimisticPointsApplied(null);
|
||||
}, [cart]);
|
||||
|
||||
// If the cart arrives already locked (e.g. user closed the page mid-checkout)
|
||||
// and there is no active Stripe session, release the lock automatically.
|
||||
useEffect(() => {
|
||||
if (cart?.checkoutPending && !clientSecret) {
|
||||
cancelCheckout().catch(() => {});
|
||||
@@ -141,15 +136,11 @@ export default function CartPage() {
|
||||
}, [cart?.checkoutPending, clientSecret, cancelCheckout]);
|
||||
|
||||
async function handleQuantityChange(cartItemId, newQty) {
|
||||
if (newQty < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newQty < 1) return;
|
||||
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty }));
|
||||
try {
|
||||
await updateItem(cartItemId, newQty);
|
||||
}
|
||||
|
||||
}
|
||||
catch {
|
||||
if (cart?.items) {
|
||||
const original = cart.items.find((i) => i.cartItemId === cartItemId);
|
||||
@@ -163,10 +154,8 @@ export default function CartPage() {
|
||||
async function handleRemove(cartItemId) {
|
||||
try {
|
||||
await removeItem(cartItemId);
|
||||
}
|
||||
|
||||
catch {
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function handleApplyCoupon() {
|
||||
@@ -186,11 +175,9 @@ export default function CartPage() {
|
||||
: "";
|
||||
setCouponSuccess(`Coupon "${updated.couponCode}" applied${discountLabel ? ` (${discountLabel})` : ""}!`);
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
setCouponError(err.message);
|
||||
}
|
||||
|
||||
finally {
|
||||
setCouponLoading(false);
|
||||
}
|
||||
@@ -217,11 +204,9 @@ export default function CartPage() {
|
||||
try {
|
||||
await removeCoupon();
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
setCouponError(err.message);
|
||||
}
|
||||
|
||||
finally {
|
||||
setCouponLoading(false);
|
||||
}
|
||||
@@ -236,39 +221,38 @@ export default function CartPage() {
|
||||
if (result?.clientSecret) {
|
||||
setClientSecret(result.clientSecret);
|
||||
setCheckoutTotal(result.totalAmount);
|
||||
}
|
||||
|
||||
}
|
||||
else if (result?.status === "succeeded") {
|
||||
refreshUser().catch(() => {});
|
||||
setConfirmed(true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
setCheckoutError(err.message);
|
||||
}
|
||||
|
||||
}
|
||||
finally {
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || cartLoading) {
|
||||
return <main className="cart-page"><p className="cart-status-msg">Loading…</p></main>;
|
||||
return (
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center">
|
||||
<p className="text-[#666] text-[1.1rem]">Loading…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (confirmed) {
|
||||
return (
|
||||
<main className="cart-page">
|
||||
<div className="cart-confirmation">
|
||||
<div className="cart-confirmation-icon">✅</div>
|
||||
<h2 className="cart-confirmation-title">Order Confirmed!</h2>
|
||||
<p className="cart-confirmation-body">
|
||||
Thank you for your purchase. Your order has been placed successfully.
|
||||
</p>
|
||||
<button className="cart-continue-btn" type="button" onClick={() => router.push("/products")}>
|
||||
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
|
||||
<div className="text-center py-16 flex flex-col items-center gap-4">
|
||||
<div className="text-5xl">✅</div>
|
||||
<h2 className="text-2xl font-bold text-[#222]">Order Confirmed!</h2>
|
||||
<p className="text-[#666]">Thank you for your purchase. Your order has been placed successfully.</p>
|
||||
<button className="px-6 py-3 bg-[#e68672] text-white rounded-lg font-semibold cursor-pointer hover:bg-[#d4705e] border-none transition-colors" type="button" onClick={() => router.push("/products")}>
|
||||
Continue Shopping
|
||||
</button>
|
||||
</div>
|
||||
@@ -278,8 +262,8 @@ export default function CartPage() {
|
||||
|
||||
if (!selectedStoreId) {
|
||||
return (
|
||||
<main className="cart-page">
|
||||
<p className="cart-status-msg">Please select a store from the navigation bar to view your cart.</p>
|
||||
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
|
||||
<p className="text-center text-[#666] py-12 text-[1.1rem]">Please select a store from the navigation bar to view your cart.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -287,64 +271,61 @@ export default function CartPage() {
|
||||
const items = cart?.items ?? [];
|
||||
|
||||
return (
|
||||
<main className="cart-page">
|
||||
<h1 className="cart-title">Your Cart</h1>
|
||||
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
|
||||
<h1 className="text-3xl font-bold text-[#222] mb-8">Your Cart</h1>
|
||||
|
||||
{cartError && <p className="cart-error-msg">{cartError}</p>}
|
||||
{cartError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] mb-4">{cartError}</p>}
|
||||
|
||||
{items.length === 0 && !cartError && (
|
||||
<div className="cart-empty">
|
||||
<p className="cart-empty-msg">Your cart is empty.</p>
|
||||
<button className="cart-continue-btn" type="button" onClick={() => router.push("/products")}>
|
||||
<div className="text-center py-16 flex flex-col items-center gap-4">
|
||||
<p className="text-[#666] text-[1.1rem]">Your cart is empty.</p>
|
||||
<button className="px-6 py-3 bg-[#e68672] text-white rounded-lg font-semibold cursor-pointer hover:bg-[#d4705e] border-none transition-colors" type="button" onClick={() => router.push("/products")}>
|
||||
Browse Products
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="cart-layout">
|
||||
<div className="cart-items-section">
|
||||
<div className="flex gap-8 items-start max-[900px]:flex-col">
|
||||
{/* Items list */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.cartItemId} className="cart-item-row">
|
||||
<div key={item.cartItemId} className="flex items-center gap-4 bg-white rounded-xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)]">
|
||||
<img
|
||||
src={item.imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={item.prodName}
|
||||
className="cart-item-img"
|
||||
className="w-16 h-16 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
}}
|
||||
/>
|
||||
<div className="cart-item-details">
|
||||
<p className="cart-item-name">{item.prodName}</p>
|
||||
<p className="cart-item-unit-price">${parseFloat(item.unitPrice).toFixed(2)} each</p>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[#222] text-[0.95rem] m-0 mb-1">{item.prodName}</p>
|
||||
<p className="text-[0.85rem] text-[#888] m-0">${parseFloat(item.unitPrice).toFixed(2)} each</p>
|
||||
</div>
|
||||
<div className="cart-item-qty-controls">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="cart-qty-btn"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md border border-[#ddd] bg-white cursor-pointer hover:border-[#e68672] transition-colors text-[#333]"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) - 1)
|
||||
}
|
||||
onClick={() => handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) - 1)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="cart-qty-val">{localQuantities[item.cartItemId] ?? item.quantity}</span>
|
||||
<span className="w-8 text-center font-semibold">{localQuantities[item.cartItemId] ?? item.quantity}</span>
|
||||
<button
|
||||
className="cart-qty-btn"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md border border-[#ddd] bg-white cursor-pointer hover:border-[#e68672] transition-colors text-[#333]"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) + 1)
|
||||
}
|
||||
onClick={() => handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<p className="cart-item-line-total">
|
||||
<p className="font-bold text-[#333] min-w-[4rem] text-right">
|
||||
${(parseFloat(item.unitPrice) * (localQuantities[item.cartItemId] ?? item.quantity)).toFixed(2)}
|
||||
</p>
|
||||
<button
|
||||
className="cart-item-remove-btn"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md border-none bg-transparent cursor-pointer text-[#aaa] hover:text-[#c0392b] transition-colors"
|
||||
type="button"
|
||||
onClick={() => handleRemove(item.cartItemId)}
|
||||
>
|
||||
@@ -353,20 +334,24 @@ export default function CartPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className="cart-clear-btn" type="button" onClick={clearCart}>
|
||||
Clear Cart
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button className="px-4 py-2 rounded-lg border border-[#ddd] bg-white text-[#666] text-[0.85rem] cursor-pointer hover:border-[#aaa] transition-colors" type="button" onClick={clearCart}>
|
||||
Clear Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="cart-summary">
|
||||
<h2 className="cart-summary-title">Order Summary</h2>
|
||||
{/* Summary aside */}
|
||||
<aside className="w-80 max-[900px]:w-full bg-white rounded-xl shadow-[0_2px_12px_rgba(0,0,0,0.08)] p-6 sticky top-24 flex flex-col gap-3">
|
||||
<h2 className="text-xl font-bold text-[#222] m-0 mb-2">Order Summary</h2>
|
||||
|
||||
<div className="cart-summary-row">
|
||||
<div className="flex items-center justify-between text-[0.9rem] text-[#555]">
|
||||
<span>Subtotal</span>
|
||||
<span>${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{parseFloat(cart.discountAmount ?? 0) > 0 && (
|
||||
<div className="cart-summary-row cart-summary-discount">
|
||||
<div className="flex items-center justify-between text-[0.9rem] text-[#16a34a]">
|
||||
<span>
|
||||
Coupon discount
|
||||
{cart.couponCode && ` (${cart.couponCode}`}
|
||||
@@ -383,51 +368,53 @@ export default function CartPage() {
|
||||
<span>−${parseFloat(cart.discountAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && (
|
||||
<div className="cart-summary-row cart-summary-discount">
|
||||
<div className="flex items-center justify-between text-[0.9rem] text-[#16a34a]">
|
||||
<span>Loyalty discount ({Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts)</span>
|
||||
<span>−${parseFloat(cart.pointsDiscountAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cart-summary-row cart-summary-total">
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#eee] pt-3 font-bold text-[1rem] text-[#222]">
|
||||
<span>Total</span>
|
||||
<div className="cart-total-prices">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) && (
|
||||
<span className="cart-total-original">
|
||||
<span className="line-through text-[#aaa] text-[0.85rem] font-normal">
|
||||
${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span className={(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "cart-total-discounted" : ""}>
|
||||
<span className={(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "text-[#e68672] text-[1.1rem]" : ""}>
|
||||
${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseFloat(cart.discountAmount ?? 0) > 0 && (
|
||||
<div className="cart-savings-callout">
|
||||
<div className="bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-3 py-2 text-[0.85rem] font-semibold text-center">
|
||||
You save ${parseFloat(cart.discountAmount).toFixed(2)}!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.role === "CUSTOMER" && (
|
||||
<div className="cart-points-estimate">
|
||||
<div className="text-[0.85rem] text-[#555] bg-[#fffbeb] rounded-lg px-3 py-2">
|
||||
⭐ Earn <strong>{Math.floor(parseFloat(cart.totalAmount ?? 0))}</strong> loyalty point{Math.floor(parseFloat(cart.totalAmount ?? 0)) !== 1 ? "s" : ""} with this purchase
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.role === "CUSTOMER" && (
|
||||
<div className="cart-points-section">
|
||||
<div className="cart-points-balance-row">
|
||||
<div className="border-t border-[#eee] pt-3 flex flex-col gap-2">
|
||||
<div className="flex justify-between text-[0.85rem] text-[#555]">
|
||||
<span>Your points balance:</span>
|
||||
<strong>{cart.availableLoyaltyPoints ?? 0} pts</strong>
|
||||
</div>
|
||||
{(cart.availableLoyaltyPoints ?? 0) < 20 ? (
|
||||
<p className="cart-points-msg">You need at least 20 points to redeem $1.</p>
|
||||
<p className="text-[0.8rem] text-[#888] m-0">You need at least 20 points to redeem $1.</p>
|
||||
) : (
|
||||
<label className="cart-points-label">
|
||||
<label className="flex items-center gap-2 text-[0.85rem] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cart-points-checkbox"
|
||||
className="accent-[#e68672]"
|
||||
checked={optimisticPointsApplied !== null ? optimisticPointsApplied : !!cart.pointsApplied}
|
||||
disabled={pointsLoading}
|
||||
onChange={(e) => handleTogglePoints(e.target.checked)}
|
||||
@@ -435,9 +422,9 @@ export default function CartPage() {
|
||||
Use loyalty points for this purchase
|
||||
</label>
|
||||
)}
|
||||
{pointsError && <p className="cart-points-msg" style={{ color: "#dc2626" }}>{pointsError}</p>}
|
||||
{pointsError && <p className="text-[0.8rem] text-[#dc2626] m-0">{pointsError}</p>}
|
||||
{(optimisticPointsApplied ?? !!cart.pointsApplied) && parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && (
|
||||
<div className="cart-points-applied-detail">
|
||||
<div className="flex justify-between text-[0.82rem] text-[#16a34a] font-semibold">
|
||||
<span>Applying {Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts:</span>
|
||||
<span>${parseFloat(cart.pointsDiscountAmount).toFixed(2)} off</span>
|
||||
</div>
|
||||
@@ -445,10 +432,11 @@ export default function CartPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cart-coupon-section">
|
||||
{/* Coupon section */}
|
||||
<div className="border-t border-[#eee] pt-3 flex flex-col gap-2">
|
||||
{cart.couponCode && (
|
||||
<div className="cart-coupon-applied">
|
||||
<span className="cart-coupon-badge">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="bg-[#e68672]/10 text-[#e68672] text-[0.82rem] font-semibold rounded-full px-3 py-1">
|
||||
{cart.couponCode}
|
||||
{(() => {
|
||||
const t = cart.couponDiscountType?.toUpperCase();
|
||||
@@ -460,7 +448,7 @@ export default function CartPage() {
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
className="cart-coupon-remove-btn"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full border-none bg-transparent cursor-pointer text-[#aaa] hover:text-[#c0392b] transition-colors text-[0.75rem]"
|
||||
type="button"
|
||||
onClick={handleRemoveCoupon}
|
||||
disabled={couponLoading}
|
||||
@@ -470,9 +458,9 @@ export default function CartPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="cart-coupon-input-row">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="cart-coupon-input"
|
||||
className="flex-1 px-3 py-2 border border-[#ddd] rounded-lg text-[0.85rem] outline-none focus:border-[#e68672] transition-colors"
|
||||
type="text"
|
||||
placeholder={cart.couponCode ? "Enter new code to replace" : "Coupon code"}
|
||||
value={couponInput}
|
||||
@@ -480,7 +468,7 @@ export default function CartPage() {
|
||||
onKeyDown={(e) => e.key === "Enter" && handleApplyCoupon()}
|
||||
/>
|
||||
<button
|
||||
className="cart-coupon-btn"
|
||||
className="px-4 py-2 bg-[#e68672] text-white rounded-lg text-[0.85rem] font-semibold border-none cursor-pointer hover:bg-[#d4705e] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={handleApplyCoupon}
|
||||
disabled={couponLoading || !couponInput.trim()}
|
||||
@@ -489,17 +477,17 @@ export default function CartPage() {
|
||||
</button>
|
||||
</div>
|
||||
{cart.couponCode && (
|
||||
<p className="cart-coupon-hint">Applying a new code will replace the current coupon.</p>
|
||||
<p className="text-[0.78rem] text-[#888] m-0">Applying a new code will replace the current coupon.</p>
|
||||
)}
|
||||
{couponSuccess && <p className="cart-coupon-success">{couponSuccess}</p>}
|
||||
{couponError && <p className="cart-coupon-error">{couponError}</p>}
|
||||
{couponSuccess && <p className="text-[0.85rem] text-[#16a34a] m-0">{couponSuccess}</p>}
|
||||
{couponError && <p className="text-[0.85rem] text-[#c0392b] m-0">{couponError}</p>}
|
||||
</div>
|
||||
|
||||
{checkoutError && <p className="cart-error-msg">{checkoutError}</p>}
|
||||
{checkoutError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{checkoutError}</p>}
|
||||
|
||||
{!clientSecret && (
|
||||
<button
|
||||
className="cart-checkout-btn"
|
||||
className="mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] disabled:opacity-60 disabled:cursor-not-allowed w-full"
|
||||
type="button"
|
||||
onClick={handleCheckout}
|
||||
disabled={checkoutLoading || items.length === 0}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
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 submitBtnCls = "mt-2 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";
|
||||
|
||||
function getStoreImage(store) {
|
||||
if (store.imageUrl) return store.imageUrl;
|
||||
const name = store.storeName?.toLowerCase() ?? "";
|
||||
@@ -62,51 +66,36 @@ export default function ContactPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="info-page">
|
||||
<section className="info-hero">
|
||||
<h1 className="info-title">Contact Us</h1>
|
||||
<p className="info-subtitle">Reach the team, find a location, or send us a message.</p>
|
||||
<div className="title-decoration"></div>
|
||||
<main className="min-h-screen">
|
||||
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[768px]:text-3xl max-[480px]:text-[1.6rem]">Contact Us</h1>
|
||||
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.2rem]">Reach the team, find a location, or send us a message.</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
<section className="contact-layout">
|
||||
<div className="info-card">
|
||||
<h2>Get in Touch</h2>
|
||||
<p>Email: hello@leonspetstore.com.au</p>
|
||||
<p>Phone: (03) 9000 0000</p>
|
||||
<p>Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-16 grid grid-cols-2 gap-8 max-[900px]:grid-cols-1">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8">
|
||||
<h2 className="text-[1.4rem] font-bold text-[#222] mb-4">Get in Touch</h2>
|
||||
<p className="text-[#555] mb-2">Email: hello@leonspetstore.com.au</p>
|
||||
<p className="text-[#555] mb-2">Phone: (03) 9000 0000</p>
|
||||
<p className="text-[#555] mb-6">Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
||||
|
||||
<div className="contact-form-section">
|
||||
<h3>Send Us a Message</h3>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-[1.1rem] font-bold text-[#333] mb-4">Send Us a Message</h3>
|
||||
{sendSuccess ? (
|
||||
<p className="contact-success">Your message has been sent. We'll be in touch soon.</p>
|
||||
<p className="bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]">Your message has been sent. We'll be in touch soon.</p>
|
||||
) : (
|
||||
<form className="auth-form" onSubmit={handleSend}>
|
||||
<label className="auth-label">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSend}>
|
||||
<label className={labelCls}>
|
||||
Subject
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
maxLength={150}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required maxLength={150} />
|
||||
</label>
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Message
|
||||
<textarea
|
||||
className="auth-input"
|
||||
style={{ resize: "vertical" }}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
required
|
||||
maxLength={2000}
|
||||
rows={5}
|
||||
/>
|
||||
<textarea className={`${inputCls} resize-y`} value={body} onChange={(e) => setBody(e.target.value)} required maxLength={2000} rows={5} />
|
||||
</label>
|
||||
{sendError && <p className="contact-error">{sendError}</p>}
|
||||
<button className="auth-submit-btn" type="submit" disabled={sending}>
|
||||
{sendError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem]">{sendError}</p>}
|
||||
<button className={submitBtnCls} type="submit" disabled={sending}>
|
||||
{sending ? "Sending…" : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -114,33 +103,33 @@ export default function ContactPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Store Locations</h2>
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8">
|
||||
<h2 className="text-[1.4rem] font-bold text-[#222] mb-4">Store Locations</h2>
|
||||
|
||||
{loading && <p>Loading locations...</p>}
|
||||
{error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>}
|
||||
{!loading && !error && locations.length === 0 && <p>No store locations found.</p>}
|
||||
{loading && <p className="text-[#666]">Loading locations...</p>}
|
||||
{error && <p className="text-[#c0392b]">{error}</p>}
|
||||
{!loading && !error && locations.length === 0 && <p className="text-[#666]">No store locations found.</p>}
|
||||
|
||||
{!loading && !error && locations.length > 0 && (
|
||||
<div className="info-card-grid">
|
||||
<div className="grid grid-cols-2 gap-4 max-[600px]:grid-cols-1">
|
||||
{locations.map((location) => (
|
||||
<article key={location.storeId} className="info-mini-card location-card">
|
||||
<div className="location-card-image-wrapper">
|
||||
<article key={location.storeId} className="rounded-xl border border-[#eee] overflow-hidden">
|
||||
<div className="aspect-video overflow-hidden bg-[#f5f5f5]">
|
||||
<img
|
||||
src={getStoreImage(location)}
|
||||
alt={location.storeName}
|
||||
className="location-card-image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="location-card-body">
|
||||
<h3>{location.storeName}</h3>
|
||||
<p>{location.address}</p>
|
||||
<p>{location.phone}</p>
|
||||
<p>{location.email}</p>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-[#222] mb-1">{location.storeName}</h3>
|
||||
<p className="text-[0.85rem] text-[#666] mb-0.5">{location.address}</p>
|
||||
<p className="text-[0.85rem] text-[#666] mb-0.5">{location.phone}</p>
|
||||
<p className="text-[0.85rem] text-[#666]">{location.email}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,10 @@ import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
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 submitBtnCls = "mt-2 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";
|
||||
|
||||
function ForgotPasswordPage() {
|
||||
const [usernameOrEmail, setUsernameOrEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -40,47 +44,40 @@ function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Forgot Password</h1>
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Forgot Password</h1>
|
||||
|
||||
{!submitted ? (
|
||||
<>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.25rem", fontSize: "0.95rem" }}>
|
||||
<p className="text-[#6b7280] mb-5 text-[0.95rem]">
|
||||
Enter your username or email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{error && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem] mb-2">{error}</p>}
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-label">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<label className={labelCls}>
|
||||
Username or Email
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
value={usernameOrEmail}
|
||||
onChange={(e) => setUsernameOrEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input className={inputCls} type="text" value={usernameOrEmail} onChange={(e) => setUsernameOrEmail(e.target.value)} required autoComplete="username" />
|
||||
</label>
|
||||
|
||||
<button className="auth-submit-btn" type="submit" disabled={loading}>
|
||||
<button className={submitBtnCls} type="submit" disabled={loading}>
|
||||
{loading ? "Sending…" : "Send Reset Link"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: "#16a34a", margin: "1rem 0", lineHeight: 1.6 }}>{message}</p>
|
||||
<p className="text-[#16a34a] my-4 leading-relaxed">{message}</p>
|
||||
)}
|
||||
|
||||
<p className="auth-switch">
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Remember your password?{" "}
|
||||
<Link href="/login" className="auth-switch-link">Log in here</Link>
|
||||
<Link href="/login" className="text-[#e68672] font-semibold no-underline hover:underline">Log in here</Link>
|
||||
</p>
|
||||
<p className="auth-switch">
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="auth-switch-link">Register here</Link>
|
||||
<Link href="/register" className="text-[#e68672] font-semibold no-underline hover:underline">Register here</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
3296
web/app/globals.css
3296
web/app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,13 @@ import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
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 submitBtnCls = "mt-2 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";
|
||||
|
||||
function resolveNextPath(candidate) {
|
||||
if (!candidate || !candidate.startsWith("/")) {
|
||||
return "/";
|
||||
}
|
||||
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) {
|
||||
return "/";
|
||||
}
|
||||
if (!candidate || !candidate.startsWith("/")) return "/";
|
||||
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) return "/";
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -30,68 +30,46 @@ function LoginPage() {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
router.push(resolveNextPath(searchParams.get("next")));
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
|
||||
finally {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Log In</h1>
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Log In</h1>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{error && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem] mb-2">{error}</p>}
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-label">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<label className={labelCls}>
|
||||
Username
|
||||
<input className="auth-input"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"/>
|
||||
<input className={inputCls} type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Password
|
||||
<input className="auth-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"/>
|
||||
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</label>
|
||||
|
||||
<button className="auth-submit-btn" type="submit" disabled={loading}>
|
||||
{loading ? "Logging in…" : "Log In"}
|
||||
</button>
|
||||
<button className={submitBtnCls} type="submit" disabled={loading}>{loading ? "Logging in…" : "Log In"}</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
Don't have an account?{" "}
|
||||
<Link href={searchParams.get("next") ? `/register?next=${encodeURIComponent(searchParams.get("next"))}` : "/register"} className="auth-switch-link">Register here</Link>
|
||||
</p>
|
||||
|
||||
<p className="auth-switch">
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Don't have an account?{" "}
|
||||
<Link href={searchParams.get("next") ? `/register?next=${encodeURIComponent(searchParams.get("next"))}` : "/register"} className="text-[#e68672] font-semibold no-underline hover:underline">Register here</Link>
|
||||
</p>
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Forgot your password?{" "}
|
||||
<Link href="/forgot-password" className="auth-switch-link">Reset it here</Link>
|
||||
<Link href="/forgot-password" className="text-[#e68672] font-semibold no-underline hover:underline">Reset it here</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default dynamic(() => Promise.resolve(LoginPage), {
|
||||
ssr: false,
|
||||
});
|
||||
export default dynamic(() => Promise.resolve(LoginPage), { ssr: false });
|
||||
|
||||
@@ -20,85 +20,66 @@ const navImages = [
|
||||
export default function Home() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
//Auto-advance slideshow
|
||||
useEffect(() => {
|
||||
//Change slide every 7.5 seconds
|
||||
const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500);
|
||||
|
||||
const timer = setInterval(() => { setCurrentSlide((prev) => (prev + 1) % slideshowImages.length); }, 7500);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="home-page">
|
||||
<main className="min-h-screen">
|
||||
{/* Slideshow */}
|
||||
<section className="slideshow-container">
|
||||
<section className="relative w-full h-[500px] overflow-hidden max-[1024px]:h-[400px] max-[768px]:h-[300px] max-[480px]:h-[250px]">
|
||||
{slideshowImages.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`slide ${index === currentSlide ? "active" : ""}`}
|
||||
>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
fill
|
||||
priority={index === 0}
|
||||
className="slide-image"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div key={image.id} className={`slide ${index === currentSlide ? "active" : ""}`}>
|
||||
<Image src={image.src} alt={image.alt} fill priority={index === 0} className="object-cover" sizes="100vw" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Title Section */}
|
||||
<section className="centered-title-section">
|
||||
<h1 className="main-title">Welcome to Leon's Pet Store</h1>
|
||||
<p className="subtitle">Your One-Stop Shop for All Things Pets</p>
|
||||
<div className="title-decoration"></div>
|
||||
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white max-[480px]:py-8 max-[480px]:px-4">
|
||||
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[1024px]:text-4xl max-[768px]:text-3xl max-[480px]:text-2xl">Welcome to Leon's Pet Store</h1>
|
||||
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.25rem] max-[480px]:text-base">Your One-Stop Shop for All Things Pets</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
{/* Image Hyperlinks */}
|
||||
<section className="image-links-section">
|
||||
<div className="image-links-container">
|
||||
{navImages.map((item, index) => (
|
||||
<Link href={item.link} key={item.id} className="image-link-card">
|
||||
<div className="image-wrapper">
|
||||
<Image
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
fill
|
||||
className="linked-image"
|
||||
sizes="(max-width: 768px) 100vw, 25vw"
|
||||
/>
|
||||
<section className="max-w-[1200px] mx-auto px-8 py-8 max-[768px]:px-4">
|
||||
<div className="grid grid-cols-3 gap-8 max-[768px]:grid-cols-2 max-[768px]:gap-6 max-[480px]:grid-cols-1 max-[480px]:gap-4">
|
||||
{navImages.map((item) => (
|
||||
<Link href={item.link} key={item.id} className="no-underline text-inherit transition-transform duration-300 hover:-translate-y-1.5 flex flex-col items-center group">
|
||||
<div className="relative w-full aspect-square rounded-[20px] overflow-hidden shadow-[0_4px_8px_rgba(0,0,0,0.1)] mb-4">
|
||||
<Image src={item.src} alt={item.alt} fill className="object-cover transition-transform duration-300 group-hover:scale-110" sizes="(max-width: 768px) 100vw, 25vw" />
|
||||
</div>
|
||||
<h3 className="image-title">{item.title}</h3>
|
||||
<h3 className="text-center text-[1.2rem] font-semibold text-[#333] mt-2">{item.title}</h3>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Us */}
|
||||
<section className="info-page">
|
||||
<div className="info-hero">
|
||||
<h2 className="info-title">About Us</h2>
|
||||
<p className="info-subtitle">A full-service pet store built on a love for animals and community.</p>
|
||||
<div className="title-decoration"></div>
|
||||
<section className="bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<div className="text-center px-8 pt-10 pb-6">
|
||||
<h2 className="text-[1.6rem] font-bold text-[#333] mb-2 uppercase tracking-[0.08em]">About Us</h2>
|
||||
<p className="text-base text-[#888] mb-4 max-w-[520px] mx-auto leading-relaxed">A full-service pet store built on a love for animals and community.</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</div>
|
||||
<div className="info-content">
|
||||
<div className="info-card">
|
||||
<h3>What We Do</h3>
|
||||
<div className="max-w-[1200px] mx-auto px-8 pb-6 grid grid-cols-3 gap-6 max-[768px]:grid-cols-1">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h3 className="mt-0 mb-4 text-[#222]">What We Do</h3>
|
||||
<p>Leon's Pet Store is a full-service pet shop offering adoptions, grooming, veterinary appointments, and a wide range of supplies to keep your pets happy and healthy.</p>
|
||||
</div>
|
||||
<div className="info-card">
|
||||
<h3>Our Focus</h3>
|
||||
<ul className="info-list">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h3 className="mt-0 mb-4 text-[#222]">Our Focus</h3>
|
||||
<ul className="m-0 pl-5 grid gap-2 list-disc">
|
||||
<li>Support responsible pet adoption</li>
|
||||
<li>Provide grooming and care services</li>
|
||||
<li>Offer reliable pet supplies</li>
|
||||
<li>Create a friendly customer experience</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="info-card">
|
||||
<h3>Visit the Store</h3>
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_12px_rgba(0,0,0,0.08)] p-6">
|
||||
<h3 className="mt-0 mb-4 text-[#222]">Visit the Store</h3>
|
||||
<p>Come visit us in person or explore our services online. Whether you're a first-time pet owner or a seasoned animal lover, we're here to help every step of the way.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,16 +14,10 @@ export default function ProductDetailPage() {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
fetch(`${API_BASE}/api/v1/products/${id}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} – ${res.statusText}`);
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} – ${res.statusText}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setProduct(data))
|
||||
@@ -32,12 +26,12 @@ export default function ProductDetailPage() {
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<main className="pet-detail-page">
|
||||
<div className="pet-detail-container">
|
||||
<Link href="/products" className="pet-detail-back">← Back to Products</Link>
|
||||
<main className="min-h-screen py-12 px-8 pb-20">
|
||||
<div className="max-w-[860px] mx-auto">
|
||||
<Link href="/products" className="inline-block mb-8 text-[#e68672] no-underline text-base font-semibold transition-colors hover:text-[#d4705e]">← Back to Products</Link>
|
||||
|
||||
{loading && <p className="adopt-status-msg">Loading product details...</p>}
|
||||
{error && <p className="adopt-status-msg adopt-error">{error}</p>}
|
||||
{loading && <p className="text-center text-[#666] text-[1.1rem] py-12">Loading product details...</p>}
|
||||
{error && <p className="text-center text-[#c0392b] text-[1.1rem] py-12">{error}</p>}
|
||||
|
||||
{!loading && !error && product && (
|
||||
<ProductProfile
|
||||
|
||||
@@ -21,21 +21,12 @@ export default function ProductsPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(0);
|
||||
|
||||
fetchAllPages((page) => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(PAGE_SIZE),
|
||||
sort: "prodId,asc",
|
||||
});
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
}
|
||||
const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE), sort: "prodId,asc" });
|
||||
if (query) params.set("q", query);
|
||||
return `${API_BASE}/api/v1/products?${params}`;
|
||||
})
|
||||
.then((allProducts) => {
|
||||
setProducts(allProducts);
|
||||
})
|
||||
.then(setProducts)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [query]);
|
||||
@@ -52,27 +43,27 @@ export default function ProductsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="products-page">
|
||||
<section className="products-hero">
|
||||
<h1 className="products-hero-title">Shop Our Products</h1>
|
||||
<p className="products-hero-subtitle">Everything your pet needs, all in one place</p>
|
||||
<div className="title-decoration"></div>
|
||||
<main className="min-h-screen">
|
||||
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[768px]:text-3xl max-[480px]:text-[1.6rem]">Shop Our Products</h1>
|
||||
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.2rem]">Everything your pet needs, all in one place</p>
|
||||
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
|
||||
</section>
|
||||
|
||||
<section className="adopt-controls">
|
||||
<div className="adopt-controls-row">
|
||||
<form className="adopt-search-form" onSubmit={handleSearch}>
|
||||
<section className="max-w-[1200px] mx-auto mb-6 px-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<form className="flex gap-3 items-center" onSubmit={handleSearch}>
|
||||
<input
|
||||
className="adopt-search-input"
|
||||
className="flex-1 max-w-[400px] px-4 py-[0.6rem] border-2 border-[#ddd] rounded-md text-base outline-none transition-colors focus:border-[#e68672] font-[inherit]"
|
||||
type="text"
|
||||
placeholder="Search by name or category..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<button className="adopt-search-btn" type="submit">Search</button>
|
||||
<button className="px-[1.4rem] py-[0.6rem] bg-[#e68672] text-white border-none rounded-md text-base cursor-pointer transition-colors hover:bg-[#d4705e] font-[inherit]" type="submit">Search</button>
|
||||
{query && (
|
||||
<button
|
||||
className="adopt-clear-btn"
|
||||
className="px-4 py-[0.6rem] bg-transparent text-[#666] border-2 border-[#ddd] rounded-md text-base cursor-pointer transition-all hover:border-[#aaa] hover:text-[#333] font-[inherit]"
|
||||
type="button"
|
||||
onClick={() => { setLoading(true); setError(null); setSearch(""); setQuery(""); }}
|
||||
>
|
||||
@@ -83,19 +74,15 @@ export default function ProductsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="adopt-grid-section">
|
||||
{loading && <p className="adopt-status-msg">Loading products...</p>}
|
||||
|
||||
{error && (
|
||||
<p className="adopt-status-msg">Unable to load products, please try again later.</p>
|
||||
)}
|
||||
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-16">
|
||||
{loading && <p className="text-center text-[#666] text-[1.1rem] py-12">Loading products...</p>}
|
||||
{error && <p className="text-center text-[#666] text-[1.1rem] py-12">Unable to load products, please try again later.</p>}
|
||||
{!loading && !error && products.length === 0 && (
|
||||
<p className="adopt-status-msg">No products found.</p>
|
||||
<p className="text-center text-[#666] text-[1.1rem] py-12">No products found.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length > 0 && (
|
||||
<div className="adopt-grid">
|
||||
<div className="grid grid-cols-4 gap-7 max-[1024px]:grid-cols-3 max-[480px]:grid-cols-2 max-[360px]:grid-cols-1">
|
||||
{displayedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.prodId}
|
||||
@@ -108,18 +95,19 @@ export default function ProductsPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalPages > 1 && (
|
||||
<div className="pagination-controls">
|
||||
<div className="flex items-center justify-center gap-[0.4rem] py-6 px-4 flex-wrap mt-6">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
className="border-none rounded-lg px-[0.9rem] py-2 text-[0.9rem] font-semibold cursor-pointer transition-colors bg-[#e8e8e8] text-[#333] hover:bg-[#d0d0d0] disabled:bg-[#f0f0f0] disabled:text-[#aaa] disabled:cursor-not-allowed"
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="pagination-info">Page {currentPage + 1} of {totalPages}</span>
|
||||
<span className="text-[0.9rem] text-[#555] font-medium">Page {currentPage + 1} of {totalPages}</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
className="border-none rounded-lg px-[0.9rem] py-2 text-[0.9rem] font-semibold cursor-pointer transition-colors bg-[#e8e8e8] text-[#333] hover:bg-[#d0d0d0] disabled:bg-[#f0f0f0] disabled:text-[#aaa] disabled:cursor-not-allowed"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,13 @@ const SPECIES_BREEDS = {
|
||||
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();
|
||||
@@ -53,7 +60,6 @@ export default function ProfilePage() {
|
||||
if (!loading && !user) {
|
||||
router.replace(`/login?next=${encodeURIComponent("/profile")}`);
|
||||
}
|
||||
|
||||
}, [user, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -227,9 +233,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("avatar", file);
|
||||
@@ -343,21 +347,19 @@ export default function ProfilePage() {
|
||||
|
||||
closeForm();
|
||||
loadPets();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
setPetError(err.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePet(id) {
|
||||
if (!confirm("Remove this pet profile?")) {
|
||||
return;
|
||||
}
|
||||
if (!confirm("Remove this pet profile?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
|
||||
@@ -394,15 +396,19 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
loadPets();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch {
|
||||
alert("Failed to upload image");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !user) {
|
||||
return <main className="auth-page"><p className="profile-loading">Loading…</p></main>;
|
||||
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;
|
||||
@@ -418,101 +424,66 @@ export default function ProfilePage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="profile-page-layout">
|
||||
<div className="profile-card">
|
||||
<div className="profile-avatar-circle">
|
||||
<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="profile-avatar-image" />
|
||||
<img src={avatarObjectUrl} alt={displayName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="profile-name">{displayName}</h1>
|
||||
<span className="profile-role-badge">{user.role}</span>
|
||||
<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="profile-fields">
|
||||
<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="profile-field-row">
|
||||
<dt className="profile-field-label">{label}</dt>
|
||||
<dd className="profile-field-value">{value}</dd>
|
||||
<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="profile-update-form" onSubmit={handleProfileSubmit}>
|
||||
<h2 className="profile-update-title">Update Profile</h2>
|
||||
{profileError && <div className="appt-error">{profileError}</div>}
|
||||
{profileSuccess && <div className="appt-success">{profileSuccess}</div>}
|
||||
<label className="appt-label">
|
||||
<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="appt-input"
|
||||
type="text"
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, firstName: e.target.value }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={profileForm.firstName} onChange={(e) => setProfileForm((c) => ({ ...c, firstName: e.target.value }))} maxLength={50} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Last Name
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, lastName: e.target.value }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={profileForm.lastName} onChange={(e) => setProfileForm((c) => ({ ...c, lastName: e.target.value }))} maxLength={50} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Email
|
||||
<input
|
||||
className="appt-input"
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, email: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<input className={inputCls} type="email" value={profileForm.email} onChange={(e) => setProfileForm((c) => ({ ...c, email: e.target.value }))} required />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Phone
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={profileForm.phone}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, phone: e.target.value }))}
|
||||
maxLength={20}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={profileForm.phone} onChange={(e) => setProfileForm((c) => ({ ...c, phone: e.target.value }))} maxLength={20} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
New Password
|
||||
<input
|
||||
className="appt-input"
|
||||
type="password"
|
||||
value={profileForm.password}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, password: e.target.value }))}
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
<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="appt-label">
|
||||
<label className={labelCls}>
|
||||
Confirm New Password
|
||||
<input
|
||||
className="appt-input"
|
||||
type="password"
|
||||
value={profileForm.confirmPassword}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, confirmPassword: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
<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="profile-avatar-actions">
|
||||
<label className="profile-avatar-upload-btn">
|
||||
|
||||
<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="profile-pet-upload-input"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleAvatarUpload(e.target.files?.[0] || null);
|
||||
e.target.value = "";
|
||||
@@ -521,92 +492,70 @@ export default function ProfilePage() {
|
||||
{avatarSubmitting ? "Working..." : user.avatarUrl ? "Change Avatar" : "Upload Avatar"}
|
||||
</label>
|
||||
{user.avatarUrl && (
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={handleAvatarDelete} disabled={avatarSubmitting}>
|
||||
<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="appt-submit-btn" disabled={profileSubmitting || avatarSubmitting}>
|
||||
|
||||
<button type="submit" className={submitBtnCls} disabled={profileSubmitting || avatarSubmitting}>
|
||||
{profileSubmitting ? "Saving..." : "Save Profile"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button type="button" className="auth-submit-btn profile-logout-btn" onClick={handleLogout}>
|
||||
<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="profile-pets-section">
|
||||
<div className="profile-pets-header">
|
||||
<h2 className="profile-pets-title">My Pets</h2>
|
||||
<button type="button" className="profile-pets-add-btn" onClick={openAddForm}>+ Add Pet</button>
|
||||
{(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="profile-pet-form" onSubmit={handlePetSubmit}>
|
||||
<h3 className="profile-pet-form-title">
|
||||
<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="appt-error">{petError}</div>}
|
||||
<label className="appt-label">
|
||||
{petError && <div className={errorCls}>{petError}</div>}
|
||||
<label className={labelCls}>
|
||||
Name
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={petName}
|
||||
onChange={(e) => setPetName(e.target.value)}
|
||||
required
|
||||
maxLength={50}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={petName} onChange={(e) => setPetName(e.target.value)} required maxLength={50} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Species
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
>
|
||||
<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="appt-label">
|
||||
<label className={labelCls}>
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<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="appt-label">
|
||||
<label className={labelCls}>
|
||||
Age (years)
|
||||
<select
|
||||
className="appt-select"
|
||||
value={petAge}
|
||||
onChange={(e) => setPetAge(e.target.value)}
|
||||
required
|
||||
>
|
||||
<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="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className={submitBtnCls} disabled={submitting}>
|
||||
{submitting ? "Saving..." : editingPet ? "Save Changes" : "Add Pet"}
|
||||
</button>
|
||||
<button type="button" className="profile-pet-cancel-btn" onClick={closeForm}>
|
||||
<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>
|
||||
@@ -614,28 +563,24 @@ export default function ProfilePage() {
|
||||
)}
|
||||
|
||||
{loadingPets ? (
|
||||
<p className="appt-loading">Loading pets...</p>
|
||||
<p className="text-[#666] text-center py-4">Loading pets...</p>
|
||||
) : pets.length === 0 && !showForm ? (
|
||||
<p className="profile-pets-empty">No pet profiles yet. Add your first pet above!</p>
|
||||
<p className="text-center text-[#888] py-8">No pet profiles yet. Add your first pet above!</p>
|
||||
) : (
|
||||
<div className="profile-pets-grid">
|
||||
<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="profile-pet-card">
|
||||
<div className="profile-pet-card-img-area">
|
||||
<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="profile-pet-card-img"
|
||||
/>
|
||||
<img src={pet.imageUrl} alt={pet.petName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="profile-pet-card-placeholder">🐾</div>
|
||||
<div className="w-full h-full flex items-center justify-center text-4xl">🐾</div>
|
||||
)}
|
||||
<label className="profile-pet-upload-label">
|
||||
<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="profile-pet-upload-input"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files[0]) handleImageUpload(pet.customerPetId, e.target.files[0]);
|
||||
e.target.value = "";
|
||||
@@ -644,15 +589,15 @@ export default function ProfilePage() {
|
||||
📷
|
||||
</label>
|
||||
</div>
|
||||
<div className="profile-pet-card-info">
|
||||
<span className="profile-pet-card-name">{pet.petName}</span>
|
||||
<span className="profile-pet-card-detail">{pet.species}</span>
|
||||
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
|
||||
{pet.petAge != null && <span className="profile-pet-card-detail">Age: {pet.petAge === 0 ? "< 1 yr" : `${pet.petAge} yr${pet.petAge !== 1 ? "s" : ""}`}</span>}
|
||||
<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="profile-pet-card-actions">
|
||||
<button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||
<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>
|
||||
))}
|
||||
@@ -662,34 +607,34 @@ export default function ProfilePage() {
|
||||
)}
|
||||
|
||||
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
|
||||
<div className="profile-pets-section">
|
||||
<div className="profile-pets-header">
|
||||
<h2 className="profile-pets-title">Order History</h2>
|
||||
<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="appt-loading">Loading orders...</p>
|
||||
<p className="text-[#666] text-center py-4">Loading orders...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="profile-pets-empty">No orders yet.</p>
|
||||
<p className="text-center text-[#888] py-8">No orders yet.</p>
|
||||
) : (
|
||||
<div className="profile-orders-list">
|
||||
<div className="flex flex-col gap-4">
|
||||
{orders.map((order) => (
|
||||
<div key={order.saleId} className="profile-order-card">
|
||||
<div className="profile-order-header">
|
||||
<span className="profile-order-date">
|
||||
<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="profile-order-total">${Number(order.totalAmount).toFixed(2)}</span>
|
||||
<span className="text-[1rem] font-bold text-[#e68672]">${Number(order.totalAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="profile-order-meta">
|
||||
<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="profile-order-items">
|
||||
<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}>
|
||||
<li key={item.saleItemId} className="flex justify-between text-[0.85rem] text-[#555]">
|
||||
<span>{item.productName} × {item.quantity}</span>
|
||||
<span className="profile-order-item-price">${(Number(item.unitPrice) * item.quantity).toFixed(2)}</span>
|
||||
<span className="font-semibold">${(Number(item.unitPrice) * item.quantity).toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
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 submitBtnCls = "mt-2 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";
|
||||
|
||||
function resolveNextPath(candidate) {
|
||||
if (!candidate || !candidate.startsWith("/")) {
|
||||
return "/";
|
||||
@@ -58,129 +62,73 @@ function RegisterPage() {
|
||||
password: form.password,
|
||||
});
|
||||
router.push(resolveNextPath(searchParams.get("next")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Create Account</h1>
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Create Account</h1>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{error && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem] mb-2">{error}</p>}
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-label">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<label className={labelCls}>
|
||||
First Name
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={form.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input className={inputCls} type="text" name="firstName" value={form.firstName} onChange={handleChange} required />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Last Name
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={form.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input className={inputCls} type="text" name="lastName" value={form.lastName} onChange={handleChange} required />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Username
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
name="username"
|
||||
value={form.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
<input className={inputCls} type="text" name="username" value={form.username} onChange={handleChange} required minLength={3} />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Email
|
||||
<input
|
||||
className="auth-input"
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input className={inputCls} type="email" name="email" value={form.email} onChange={handleChange} required />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Phone
|
||||
<input
|
||||
className="auth-input"
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
required
|
||||
pattern="[0-9\-\+\(\) ]{7,15}"
|
||||
title="Enter a valid phone number"
|
||||
/>
|
||||
<input className={inputCls} type="tel" name="phone" value={form.phone} onChange={handleChange} required pattern="[0-9\-\+\(\) ]{7,15}" title="Enter a valid phone number" />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Password
|
||||
<input
|
||||
className="auth-input"
|
||||
type="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input className={inputCls} type="password" name="password" value={form.password} onChange={handleChange} required minLength={6} autoComplete="new-password" />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Confirm Password
|
||||
<input
|
||||
className="auth-input"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={form.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input className={inputCls} type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required minLength={6} autoComplete="new-password" />
|
||||
</label>
|
||||
|
||||
<button className="auth-submit-btn" type="submit" disabled={loading}>
|
||||
<button className={submitBtnCls} type="submit" disabled={loading}>
|
||||
{loading ? "Creating account…" : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Already have an account?{" "}
|
||||
<Link href={searchParams.get("next") ? `/login?next=${encodeURIComponent(searchParams.get("next"))}` : "/login"} className="auth-switch-link">Log in here</Link>
|
||||
<Link href={searchParams.get("next") ? `/login?next=${encodeURIComponent(searchParams.get("next"))}` : "/login"} className="text-[#e68672] font-semibold no-underline hover:underline">Log in here</Link>
|
||||
</p>
|
||||
|
||||
<p className="auth-switch">
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Forgot your password?{" "}
|
||||
<Link href="/forgot-password" className="auth-switch-link">Reset it here</Link>
|
||||
<Link href="/forgot-password" className="text-[#e68672] font-semibold no-underline hover:underline">Reset it here</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,10 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
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 submitBtnCls = "mt-2 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";
|
||||
|
||||
function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -18,14 +22,14 @@ function ResetPasswordPage() {
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Invalid Link</h1>
|
||||
<p className="auth-error">
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Invalid Link</h1>
|
||||
<p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem] mb-2">
|
||||
This password reset link is missing or invalid. Please request a new one.
|
||||
</p>
|
||||
<p className="auth-switch">
|
||||
<Link href="/forgot-password" className="auth-switch-link">Request a new reset link</Link>
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
<Link href="/forgot-password" className="text-[#e68672] font-semibold no-underline hover:underline">Request a new reset link</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
@@ -66,14 +70,14 @@ function ResetPasswordPage() {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Password Reset</h1>
|
||||
<p style={{ color: "#16a34a", margin: "1rem 0", lineHeight: 1.6 }}>
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Password Reset</h1>
|
||||
<p className="text-[#16a34a] my-4 leading-relaxed">
|
||||
Your password has been reset successfully. Redirecting you to login…
|
||||
</p>
|
||||
<p className="auth-switch">
|
||||
<Link href="/login" className="auth-switch-link">Go to login</Link>
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
<Link href="/login" className="text-[#e68672] font-semibold no-underline hover:underline">Go to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
@@ -81,46 +85,31 @@ function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Reset Password</h1>
|
||||
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center py-8 px-4 bg-[#fafafa]">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_24px_rgba(0,0,0,0.1)] p-10 w-full max-w-[440px] max-[480px]:p-6">
|
||||
<h1 className="text-[1.75rem] font-bold text-[#222] m-0 mb-6 text-center">Reset Password</h1>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
{error && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-[0.65rem] text-[0.9rem] mb-2">{error}</p>}
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-label">
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<label className={labelCls}>
|
||||
New Password
|
||||
<input
|
||||
className="auth-input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input className={inputCls} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={6} autoComplete="new-password" />
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
<label className={labelCls}>
|
||||
Confirm New Password
|
||||
<input
|
||||
className="auth-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input className={inputCls} type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
|
||||
</label>
|
||||
|
||||
<button className="auth-submit-btn" type="submit" disabled={loading}>
|
||||
<button className={submitBtnCls} type="submit" disabled={loading}>
|
||||
{loading ? "Resetting…" : "Reset Password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
<p className="text-center text-[0.9rem] text-[#666] mt-5">
|
||||
Remember your password?{" "}
|
||||
<Link href="/login" className="auth-switch-link">Log in here</Link>
|
||||
<Link href="/login" className="text-[#e68672] font-semibold no-underline hover:underline">Log in here</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
const linkCls = "text-[#2f2f2f] no-underline text-[0.95rem] opacity-85 transition-opacity hover:opacity-100 hover:underline";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="footer-container">
|
||||
<footer className="bg-[#e68672] text-[#2f2f2f] mt-16 rounded-t-[10px]">
|
||||
<div className="max-w-[1200px] mx-auto px-8 pt-12 pb-8 grid [grid-template-columns:2fr_1fr_1fr_1fr] gap-8 max-[900px]:[grid-template-columns:1fr_1fr] max-[550px]:grid-cols-1">
|
||||
|
||||
<div className="footer-brand">
|
||||
<Image
|
||||
src="/logo_simple.png"
|
||||
alt="Leon's Pet Store logo"
|
||||
width={50}
|
||||
height={50}
|
||||
className="footer-logo"
|
||||
/>
|
||||
<p className="footer-tagline">
|
||||
<div className="max-[900px]:[grid-column:1/-1]">
|
||||
<Image src="/logo_simple.png" alt="Leon's Pet Store logo" width={50} height={50} className="rounded-full mb-3" />
|
||||
<p className="text-[0.95rem] leading-relaxed opacity-90 max-w-[260px]">
|
||||
Your neighbourhood pet store!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h3 className="footer-heading">Quick Links</h3>
|
||||
<ul className="footer-links">
|
||||
<li><Link href="/">Home</Link></li>
|
||||
<li><Link href="/adopt">Adopt a Pet</Link></li>
|
||||
<li><Link href="/products">Online Store</Link></li>
|
||||
<li><Link href="/appointments">Schedule an Appointment</Link></li>
|
||||
<li><Link href="/ai-chat">AI Assistant</Link></li>
|
||||
<div>
|
||||
<h3 className="text-base font-bold mb-4 uppercase tracking-[0.05em]">Quick Links</h3>
|
||||
<ul className="list-none p-0 m-0 flex flex-col gap-2">
|
||||
<li><Link href="/" className={linkCls}>Home</Link></li>
|
||||
<li><Link href="/adopt" className={linkCls}>Adopt a Pet</Link></li>
|
||||
<li><Link href="/products" className={linkCls}>Online Store</Link></li>
|
||||
<li><Link href="/appointments" className={linkCls}>Schedule an Appointment</Link></li>
|
||||
<li><Link href="/ai-chat" className={linkCls}>AI Assistant</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h3 className="footer-heading">Company</h3>
|
||||
<ul className="footer-links">
|
||||
<li><Link href="/about">About Us</Link></li>
|
||||
<li><Link href="/contact">Contact Us</Link></li>
|
||||
<div>
|
||||
<h3 className="text-base font-bold mb-4 uppercase tracking-[0.05em]">Company</h3>
|
||||
<ul className="list-none p-0 m-0 flex flex-col gap-2">
|
||||
<li><Link href="/about" className={linkCls}>About Us</Link></li>
|
||||
<li><Link href="/contact" className={linkCls}>Contact Us</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h3 className="footer-heading">Contact</h3>
|
||||
<ul className="footer-links footer-contact">
|
||||
<li>(403) 123-4567</li>
|
||||
<li>support@leonspetstore.com</li>
|
||||
<div>
|
||||
<h3 className="text-base font-bold mb-4 uppercase tracking-[0.05em]">Contact</h3>
|
||||
<ul className="list-none p-0 m-0 flex flex-col gap-2">
|
||||
<li className="text-[0.95rem] opacity-85">(403) 123-4567</li>
|
||||
<li className="text-[0.95rem] opacity-85">support@leonspetstore.com</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<div className="border-t border-[rgba(47,47,47,0.2)] text-center px-8 py-4 text-[0.85rem] opacity-80">
|
||||
<p>© {new Date().getFullYear()} Leon's Pet Store. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -7,6 +7,22 @@ import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
const drawerLinkCls = "block text-[#2f2f2f] no-underline text-[1.05rem] font-medium px-2 py-[0.65rem] rounded-md transition-colors hover:bg-[rgba(47,47,47,0.1)]";
|
||||
const navLinkCls = "text-[#2f2f2f] no-underline text-[1.05rem] font-semibold px-4 py-2 rounded-md transition-all duration-[250ms] hover:bg-white/25";
|
||||
const cartBtnCls = "relative inline-flex items-center text-[1.4rem] no-underline mr-2 px-[0.4rem] py-[0.2rem] rounded-md transition-colors hover:bg-white/20";
|
||||
const cartBadgeCls = "absolute -top-1 -right-1.5 bg-[#e53935] text-white rounded-full text-[0.65rem] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-[3px] leading-none";
|
||||
|
||||
function CartIcon({ itemCount, onClick }) {
|
||||
return (
|
||||
<Link href="/cart" className={cartBtnCls} aria-label="Cart" onClick={onClick}>
|
||||
🛒
|
||||
{itemCount > 0 && (
|
||||
<span className={cartBadgeCls}>{itemCount > 99 ? "99+" : itemCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DisplayNav() {
|
||||
const { user, logout, loading } = useAuth();
|
||||
const { itemCount, selectedStoreId, setStoreId } = useCart();
|
||||
@@ -27,140 +43,98 @@ export default function DisplayNav() {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
function closeMenu() { setMenuOpen(false); }
|
||||
|
||||
const storeSelect = (extraCls = "") => user && stores.length > 0 && (
|
||||
<select
|
||||
className={`bg-[rgba(47,47,47,0.1)] text-[#2f2f2f] border border-[rgba(47,47,47,0.35)] rounded-md px-[0.6rem] py-[0.3rem] text-[0.9rem] cursor-pointer outline-none transition-colors hover:bg-[rgba(47,47,47,0.2)] ${extraCls}`}
|
||||
value={selectedStoreId ?? ""}
|
||||
onChange={(e) => { setStoreId(e.target.value || null); if (extraCls) closeMenu(); }}
|
||||
>
|
||||
<option value="">All Stores</option>
|
||||
{stores.map((s) => <option key={s.storeId} value={s.storeId}>{s.storeName}</option>)}
|
||||
</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<nav className="fixed top-0 left-0 w-full bg-[#e68672] shadow-[0_2px_10px_rgba(0,0,0,0.1)] z-[1000] px-8 py-2 grid [grid-template-columns:1fr_auto_1fr] items-center min-h-[70px] max-[1100px]:px-4">
|
||||
<Link href="/" onClick={closeMenu}>
|
||||
<Image
|
||||
className="mx-3"
|
||||
src="/logo_simple.png"
|
||||
alt="store_logo"
|
||||
width={50}
|
||||
height={50}
|
||||
id="logo"
|
||||
loading="eager"
|
||||
/>
|
||||
<Image className="mx-3" src="/logo_simple.png" alt="store_logo" width={50} height={50} id="logo" loading="eager" />
|
||||
</Link>
|
||||
|
||||
{/* Desktop: inline links + auth */}
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">Home</Link>
|
||||
<Link href="/adopt" className="nav-link">Adopt</Link>
|
||||
<Link href="/products" className="nav-link">Store</Link>
|
||||
<Link href="/appointments" className="nav-link">Appointments</Link>
|
||||
<Link href="/ai-chat" className="nav-link">Help</Link>
|
||||
<Link href="/contact" className="nav-link">Contact</Link>
|
||||
{/*} <Link href="/about" className="nav-link">About</Link> */}
|
||||
{/* Desktop nav links */}
|
||||
<div className="hidden min-[1101px]:flex items-center gap-5 justify-center">
|
||||
<Link href="/" className={navLinkCls}>Home</Link>
|
||||
<Link href="/adopt" className={navLinkCls}>Adopt</Link>
|
||||
<Link href="/products" className={navLinkCls}>Store</Link>
|
||||
<Link href="/appointments" className={navLinkCls}>Appointments</Link>
|
||||
<Link href="/ai-chat" className={navLinkCls}>Help</Link>
|
||||
<Link href="/contact" className={navLinkCls}>Contact</Link>
|
||||
</div>
|
||||
|
||||
<div className="nav-auth">
|
||||
{user && stores.length > 0 && (
|
||||
<select
|
||||
className="nav-store-select"
|
||||
value={selectedStoreId ?? ""}
|
||||
onChange={(e) => setStoreId(e.target.value || null)}
|
||||
>
|
||||
<option value="">All Stores</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s.storeId} value={s.storeId}>
|
||||
{s.storeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Link href="/cart" className="nav-cart-btn" aria-label="Cart">
|
||||
🛒
|
||||
{itemCount > 0 && (
|
||||
<span className="nav-cart-badge">{itemCount > 99 ? "99+" : itemCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Desktop auth */}
|
||||
<div className="hidden min-[1101px]:flex items-center gap-2 justify-self-end min-w-0 overflow-hidden whitespace-nowrap">
|
||||
{storeSelect("mr-2 max-w-[160px]")}
|
||||
{user && <CartIcon itemCount={itemCount} />}
|
||||
{loading ? null : user ? (
|
||||
<>
|
||||
<Link href="/profile" className="nav-link nav-greeting">
|
||||
<Link href="/profile" className={`${navLinkCls} font-bold`}>
|
||||
Hello, {(user.fullName || user.username).split(" ")[0]}
|
||||
</Link>
|
||||
<button type="button" className="nav-logout-btn" onClick={handleLogout}>
|
||||
<button type="button" className="bg-[rgba(47,47,47,0.15)] text-[#2f2f2f] border border-[rgba(47,47,47,0.4)] rounded-full px-4 py-[0.35rem] text-[0.95rem] cursor-pointer transition-colors whitespace-nowrap hover:bg-[rgba(47,47,47,0.25)]" onClick={handleLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="nav-link">Log In</Link>
|
||||
<Link href="/register" className="nav-link nav-register-btn">Register</Link>
|
||||
<Link href="/login" className={navLinkCls}>Log In</Link>
|
||||
<Link href="/register" className="bg-[#2f2f2f] text-[#e68672] font-semibold rounded-full px-4 py-[0.4rem] no-underline transition-colors hover:bg-[#444]">Register</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: cart icon + hamburger always in topbar */}
|
||||
<div className="nav-mobile-bar">
|
||||
{user && (
|
||||
<Link href="/cart" className="nav-cart-btn" aria-label="Cart" onClick={closeMenu}>
|
||||
🛒
|
||||
{itemCount > 0 && (
|
||||
<span className="nav-cart-badge">{itemCount > 99 ? "99+" : itemCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{/* Mobile bar: cart + hamburger */}
|
||||
<div className="flex min-[1101px]:hidden items-center gap-2 [grid-column:3] justify-self-end">
|
||||
{user && <CartIcon itemCount={itemCount} onClick={closeMenu} />}
|
||||
<button
|
||||
className={`nav-hamburger${menuOpen ? " nav-hamburger--open" : ""}`}
|
||||
className={`flex flex-col justify-center gap-[5px] w-9 h-9 bg-transparent border-none cursor-pointer p-1 rounded-md transition-colors hover:bg-[rgba(47,47,47,0.12)]${menuOpen ? " nav-hamburger--open" : ""}`}
|
||||
aria-label="Toggle navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span className="block h-[2px] w-full bg-[#2f2f2f] rounded-sm transition-all duration-[250ms] origin-center" />
|
||||
<span className="block h-[2px] w-full bg-[#2f2f2f] rounded-sm transition-all duration-[250ms] origin-center" />
|
||||
<span className="block h-[2px] w-full bg-[#2f2f2f] rounded-sm transition-all duration-[250ms] origin-center" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown drawer */}
|
||||
{menuOpen && (
|
||||
<div className="nav-drawer">
|
||||
<Link href="/" className="nav-drawer-link" onClick={closeMenu}>Home</Link>
|
||||
<Link href="/adopt" className="nav-drawer-link" onClick={closeMenu}>Adopt</Link>
|
||||
<Link href="/products" className="nav-drawer-link" onClick={closeMenu}>Store</Link>
|
||||
<Link href="/appointments" className="nav-drawer-link" onClick={closeMenu}>Appointments</Link>
|
||||
<Link href="/ai-chat" className="nav-drawer-link" onClick={closeMenu}>Help</Link>
|
||||
<Link href="/contact" className="nav-drawer-link" onClick={closeMenu}>Contact</Link>
|
||||
{/* <Link href="/about" className="nav-drawer-link" onClick={closeMenu}>About</Link> */}
|
||||
<div className="flex flex-col absolute top-[70px] left-0 w-full bg-[#e68672] px-6 pb-6 pt-4 z-[999] shadow-[0_6px_20px_rgba(0,0,0,0.15)] rounded-b-[10px] gap-1">
|
||||
<Link href="/" className={drawerLinkCls} onClick={closeMenu}>Home</Link>
|
||||
<Link href="/adopt" className={drawerLinkCls} onClick={closeMenu}>Adopt</Link>
|
||||
<Link href="/products" className={drawerLinkCls} onClick={closeMenu}>Store</Link>
|
||||
<Link href="/appointments" className={drawerLinkCls} onClick={closeMenu}>Appointments</Link>
|
||||
<Link href="/ai-chat" className={drawerLinkCls} onClick={closeMenu}>Help</Link>
|
||||
<Link href="/contact" className={drawerLinkCls} onClick={closeMenu}>Contact</Link>
|
||||
|
||||
<div className="nav-drawer-divider" />
|
||||
<div className="h-px bg-[rgba(47,47,47,0.2)] my-2" />
|
||||
|
||||
{user && stores.length > 0 && (
|
||||
<select
|
||||
className="nav-store-select nav-store-select--drawer"
|
||||
value={selectedStoreId ?? ""}
|
||||
onChange={(e) => { setStoreId(e.target.value || null); closeMenu(); }}
|
||||
>
|
||||
<option value="">All Stores</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s.storeId} value={s.storeId}>
|
||||
{s.storeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{storeSelect("w-full mb-1")}
|
||||
|
||||
{loading ? null : user ? (
|
||||
<>
|
||||
<Link href="/profile" className="nav-drawer-link" onClick={closeMenu}>
|
||||
<Link href="/profile" className={drawerLinkCls} onClick={closeMenu}>
|
||||
My Profile ({user.fullName || user.username})
|
||||
</Link>
|
||||
<button type="button" className="nav-logout-btn nav-logout-btn--drawer" onClick={handleLogout}>
|
||||
<button type="button" className="w-full bg-[rgba(47,47,47,0.15)] text-[#2f2f2f] border border-[rgba(47,47,47,0.4)] rounded-lg px-4 py-[0.65rem] text-base cursor-pointer transition-colors mt-1 hover:bg-[rgba(47,47,47,0.25)]" onClick={handleLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="nav-drawer-link" onClick={closeMenu}>Log In</Link>
|
||||
<Link href="/register" className="nav-drawer-link nav-drawer-link--register" onClick={closeMenu}>Register</Link>
|
||||
<Link href="/login" className={drawerLinkCls} onClick={closeMenu}>Log In</Link>
|
||||
<Link href="/register" className="block mt-1 bg-[#2f2f2f] text-[#e68672] font-bold rounded-full px-4 py-[0.6rem] text-center no-underline transition-colors hover:bg-[#444]" onClick={closeMenu}>Register</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getStatusClass } from "@/components/petUtils";
|
||||
|
||||
export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl}) {
|
||||
return (
|
||||
<Link href={`/adopt/${petId}`} className="pet-card">
|
||||
<div className="pet-card-image-wrapper">
|
||||
<Link href={`/adopt/${petId}`} className="no-underline text-inherit flex flex-col rounded-2xl overflow-hidden shadow-[0_4px_12px_rgba(0,0,0,0.08)] transition-all duration-300 hover:-translate-y-1.5 hover:shadow-[0_8px_24px_rgba(0,0,0,0.13)] bg-white">
|
||||
<div className="bg-[#fff8ee] flex items-center justify-center aspect-square">
|
||||
<img
|
||||
src={imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={petName}
|
||||
className="pet-card-image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pet-card-body">
|
||||
<h3 className="pet-card-name">{petName}</h3>
|
||||
<p className="pet-card-species">{petSpecies}</p>
|
||||
<span className={`pet-card-status ${getStatusClass(petStatus)}`}>
|
||||
<div className="px-3 pt-[0.6rem] pb-3 flex flex-col gap-[0.2rem]">
|
||||
<h3 className="text-[0.95rem] font-bold text-[#222] m-0 truncate">{petName}</h3>
|
||||
<p className="text-[0.8rem] text-[#666] m-0">{petSpecies}</p>
|
||||
<span className={`inline-block mt-[0.2rem] px-2 py-[0.15rem] rounded-full text-[0.7rem] font-semibold capitalize w-fit ${getStatusClass(petStatus)}`}>
|
||||
{petStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { getStatusClass } from "@/components/petUtils";
|
||||
|
||||
const fieldRowCls = "flex items-center px-5 py-[0.85rem] border-b border-[#eee] last:border-b-0";
|
||||
const fieldLabelCls = "w-[140px] text-[0.9rem] font-semibold text-[#888] uppercase tracking-[0.04em] shrink-0";
|
||||
const fieldValueCls = "text-base text-[#333]";
|
||||
|
||||
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) {
|
||||
return (
|
||||
<div className="pet-detail-card">
|
||||
<div className="pet-detail-image-wrapper">
|
||||
<div className="flex gap-12 bg-white rounded-2xl shadow-[0_6px_24px_rgba(0,0,0,0.1)] overflow-hidden max-[768px]:flex-col max-[768px]:gap-0">
|
||||
<div className="shrink-0 w-[280px] bg-[#fff8ee] flex items-center justify-center max-[768px]:w-full max-[768px]:h-[200px]">
|
||||
<img
|
||||
src={imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={petName}
|
||||
className="pet-detail-image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
@@ -16,46 +20,45 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pet-detail-info">
|
||||
<div className="pet-detail-header">
|
||||
<h1 className="pet-detail-name">{petName}</h1>
|
||||
<span className={`pet-card-status ${getStatusClass(petStatus)}`}>
|
||||
<div className="flex-1 py-10 pr-10 flex flex-col gap-6 max-[768px]:p-7">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h1 className="text-[2.2rem] font-bold text-[#222] m-0">{petName}</h1>
|
||||
<span className={`inline-block px-2 py-[0.15rem] rounded-full text-[0.7rem] font-semibold capitalize w-fit ${getStatusClass(petStatus)}`}>
|
||||
{petStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pet-detail-fields">
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Species</span>
|
||||
<span className="pet-detail-value">{petSpecies ?? "—"}</span>
|
||||
<div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden">
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Species</span>
|
||||
<span className={fieldValueCls}>{petSpecies ?? "—"}</span>
|
||||
</div>
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Breed</span>
|
||||
<span className="pet-detail-value">{petBreed ?? "—"}</span>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Breed</span>
|
||||
<span className={fieldValueCls}>{petBreed ?? "—"}</span>
|
||||
</div>
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Age</span>
|
||||
<span className="pet-detail-value">
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Age</span>
|
||||
<span className={fieldValueCls}>
|
||||
{petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Adoption Fee</span>
|
||||
<span className="pet-detail-value pet-detail-price">
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Adoption Fee</span>
|
||||
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
|
||||
{petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
{petStatus?.toLowerCase() === "available" && (
|
||||
<div className="pet-detail-cta">
|
||||
<p className="pet-detail-cta-text">
|
||||
<div className="bg-[#fff8ee] rounded-xl p-5">
|
||||
<p className="text-[0.95rem] text-[#555] m-0 mb-4">
|
||||
Interested in adopting {petName}? Visit us in store or schedule an appointment.
|
||||
</p>
|
||||
<Link
|
||||
href={`/appointments?adoptionMode=true&petId=${petId}&petName=${encodeURIComponent(petName || "")}&petSpecies=${encodeURIComponent(petSpecies || "")}&petBreed=${encodeURIComponent(petBreed || "")}${storeId ? `&storeId=${storeId}` : ""}${storeName ? `&storeName=${encodeURIComponent(storeName)}` : ""}`}
|
||||
className="pet-detail-cta-btn"
|
||||
className="inline-block px-6 py-[0.65rem] bg-[#e68672] text-white no-underline rounded-lg text-[0.95rem] font-semibold transition-colors hover:bg-[#d4705e]"
|
||||
>
|
||||
Schedule an Appointment
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
const qtyBtnCls = "w-7 h-7 border border-[#ddd] rounded-md bg-white text-base cursor-pointer flex items-center justify-center transition-colors hover:border-[#e68672] disabled:opacity-50";
|
||||
|
||||
export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) {
|
||||
const { user } = useAuth();
|
||||
const { addItem, selectedStoreId } = useCart();
|
||||
@@ -16,10 +18,7 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice,
|
||||
|
||||
async function handleAddToCart(e) {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (!user) { router.push("/login"); return; }
|
||||
if (!selectedStoreId) {
|
||||
setFeedback("Please select a store first");
|
||||
setTimeout(() => setFeedback(null), 2500);
|
||||
@@ -40,57 +39,43 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pet-card product-card-wrapper">
|
||||
<Link href={`/products/${prodId}`} className="product-card-link">
|
||||
<div className="pet-card-image-wrapper">
|
||||
<div className="flex flex-col no-underline rounded-2xl overflow-hidden shadow-[0_4px_12px_rgba(0,0,0,0.08)] transition-all duration-300 hover:-translate-y-1.5 hover:shadow-[0_8px_24px_rgba(0,0,0,0.13)] bg-white">
|
||||
<Link href={`/products/${prodId}`} className="no-underline text-inherit flex-1">
|
||||
<div className="bg-[#fff8ee] flex items-center justify-center aspect-square">
|
||||
<img
|
||||
src={imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={prodName}
|
||||
className="pet-card-image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pet-card-body">
|
||||
<h3 className="pet-card-name">{prodName}</h3>
|
||||
<p className="pet-card-species">{categoryName}</p>
|
||||
<div className="px-3 pt-[0.6rem] pb-3 flex flex-col gap-[0.2rem]">
|
||||
<h3 className="text-[0.95rem] font-bold text-[#222] m-0 truncate">{prodName}</h3>
|
||||
<p className="text-[0.8rem] text-[#666] m-0">{categoryName}</p>
|
||||
{prodPrice != null && (
|
||||
<span className="product-card-price">${parseFloat(prodPrice).toFixed(2)}</span>
|
||||
<span className="inline-block mt-1 text-[1.05rem] font-bold text-[#1a7a3c]">${parseFloat(prodPrice).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="product-card-actions">
|
||||
<div className="product-card-qty-row">
|
||||
<button
|
||||
className="product-card-qty-btn"
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={adding}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="product-card-qty-val">{quantity}</span>
|
||||
<button
|
||||
className="product-card-qty-btn"
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
disabled={adding}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={qtyBtnCls} type="button" onClick={() => setQuantity((q) => Math.max(1, q - 1))} disabled={adding}>−</button>
|
||||
<span className="min-w-6 text-center font-semibold text-[0.9rem]">{quantity}</span>
|
||||
<button className={qtyBtnCls} type="button" onClick={() => setQuantity((q) => q + 1)} disabled={adding}>+</button>
|
||||
</div>
|
||||
<button
|
||||
className="product-card-add-btn"
|
||||
className="w-full py-[0.45rem] bg-[#e68672] text-white border-none rounded-lg text-[0.88rem] font-bold cursor-pointer transition-colors hover:bg-[#d4705e] disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "Adding…" : "Add to Cart"}
|
||||
</button>
|
||||
{feedback && <p className="product-card-feedback">{feedback}</p>}
|
||||
{feedback && <p className="text-[0.78rem] text-[#2e7d32] m-0 text-center">{feedback}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,17 +4,20 @@ import { useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
const fieldRowCls = "flex items-center px-5 py-[0.85rem] border-b border-[#eee] last:border-b-0";
|
||||
const fieldLabelCls = "w-[140px] text-[0.9rem] font-semibold text-[#888] uppercase tracking-[0.04em] shrink-0";
|
||||
const fieldValueCls = "text-base text-[#333]";
|
||||
const qtyBtnCls = "w-7 h-7 border border-[#ddd] rounded-md bg-white text-base cursor-pointer flex items-center justify-center transition-colors hover:border-[#e68672] disabled:opacity-50";
|
||||
|
||||
export default function ProductProfile({ prodId, prodName, categoryName, prodDesc, prodPrice, imageUrl }) {
|
||||
const { user } = useAuth();
|
||||
const { addItem, selectedStoreId } = useCart();
|
||||
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null); // { type: "success"|"error", message }
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
function changeQty(delta) {
|
||||
setQuantity((q) => Math.max(1, q + delta));
|
||||
}
|
||||
function changeQty(delta) { setQuantity((q) => Math.max(1, q + delta)); }
|
||||
|
||||
async function handleAddToCart() {
|
||||
setAdding(true);
|
||||
@@ -31,12 +34,12 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pet-detail-card">
|
||||
<div className="pet-detail-image-wrapper">
|
||||
<div className="flex gap-12 bg-white rounded-2xl shadow-[0_6px_24px_rgba(0,0,0,0.1)] overflow-hidden max-[768px]:flex-col max-[768px]:gap-0">
|
||||
<div className="shrink-0 w-[280px] bg-[#fff8ee] flex items-center justify-center max-[768px]:w-full max-[768px]:h-[200px]">
|
||||
<img
|
||||
src={imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={prodName}
|
||||
className="pet-detail-image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
@@ -44,64 +47,50 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pet-detail-info">
|
||||
<div className="pet-detail-header">
|
||||
<h1 className="pet-detail-name">{prodName}</h1>
|
||||
<div className="flex-1 py-10 pr-10 flex flex-col gap-6 max-[768px]:p-7">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h1 className="text-[2.2rem] font-bold text-[#222] m-0">{prodName}</h1>
|
||||
</div>
|
||||
|
||||
<div className="pet-detail-fields">
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Category</span>
|
||||
<span className="pet-detail-value">{categoryName ?? "—"}</span>
|
||||
<div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden">
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Category</span>
|
||||
<span className={fieldValueCls}>{categoryName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Price</span>
|
||||
<span className="pet-detail-value pet-detail-price">
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Price</span>
|
||||
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
|
||||
{prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pet-detail-row">
|
||||
<span className="pet-detail-label">Description</span>
|
||||
<span className="pet-detail-value">{prodDesc ?? "—"}</span>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Description</span>
|
||||
<span className={fieldValueCls}>{prodDesc ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pet-detail-cta">
|
||||
<div className="bg-[#fff8ee] rounded-xl p-5">
|
||||
{!user ? (
|
||||
<p className="pet-detail-cta-text">
|
||||
<a href="/login" className="appt-link">Log in</a> to purchase this item.
|
||||
<p className="text-[0.95rem] text-[#555] m-0">
|
||||
<a href="/login" className="text-[#e68672] font-semibold no-underline hover:underline">Log in</a> to purchase this item.
|
||||
</p>
|
||||
) : !selectedStoreId ? (
|
||||
<p className="pet-detail-cta-text">
|
||||
<p className="text-[0.95rem] text-[#555] m-0">
|
||||
Select a store from the navigation bar to add items to your cart.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="product-qty-row">
|
||||
<span className="product-qty-label">Quantity</span>
|
||||
<div className="product-qty-controls">
|
||||
<button
|
||||
className="cart-qty-btn"
|
||||
onClick={() => changeQty(-1)}
|
||||
disabled={quantity <= 1 || adding}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="cart-qty-val">{quantity}</span>
|
||||
<button
|
||||
className="cart-qty-btn"
|
||||
onClick={() => changeQty(1)}
|
||||
disabled={adding}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="text-[0.95rem] font-semibold text-[#555]">Quantity</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={qtyBtnCls} onClick={() => changeQty(-1)} disabled={quantity <= 1 || adding} aria-label="Decrease quantity">−</button>
|
||||
<span className="min-w-7 text-center font-semibold">{quantity}</span>
|
||||
<button className={qtyBtnCls} onClick={() => changeQty(1)} disabled={adding} aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="product-add-to-cart-btn"
|
||||
className="w-full py-[0.85rem] bg-[#e68672] text-[#2f2f2f] border-none rounded-[10px] text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] hover:text-white active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={handleAddToCart}
|
||||
disabled={adding}
|
||||
>
|
||||
@@ -109,7 +98,7 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
|
||||
</button>
|
||||
|
||||
{feedback && (
|
||||
<p className={`product-cart-feedback product-cart-feedback--${feedback.type}`}>
|
||||
<p className={`mt-3 text-[0.9rem] rounded-lg px-4 py-[0.6rem] ${feedback.type === "success" ? "bg-[#f0fff4] border border-[#b2dfdb] text-[#1a7a3c]" : "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b]"}`}>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user