Merge pull request #337 from RecentRunner/web-lastfixes

web last fixes
This commit was merged in pull request #337.
This commit is contained in:
2026-04-20 19:31:55 -06:00
committed by GitHub
34 changed files with 402 additions and 104 deletions

View File

@@ -199,6 +199,7 @@ INSERT INTO service_species (serviceId, species) VALUES
(1, 'Dog'), (1, 'Dog'),
(1, 'Cat'), (1, 'Cat'),
(1, 'Rabbit'), (1, 'Rabbit'),
(1, 'Bird'),
(2, 'Dog'), (2, 'Dog'),
(2, 'Cat'), (2, 'Cat'),
(2, 'Rabbit'), (2, 'Rabbit'),

View File

@@ -1,3 +1,5 @@
//About page
//Three info cards covering what the store does, its focus, and how to visit
export default function AboutPage() { export default function AboutPage() {
return ( return (
<main className="bg-gradient-to-b from-[#f9f9f9] to-white"> <main className="bg-gradient-to-b from-[#f9f9f9] to-white">

View File

@@ -7,6 +7,8 @@ import PetProfile from "@/components/PetProfile";
const API_BASE = ""; const API_BASE = "";
//Pet detail page
//Fetches a single pet by ID and passes it to PetProfile
export default function PetDetailPage() { export default function PetDetailPage() {
const { id } = useParams(); const { id } = useParams();
const [pet, setPet] = useState(null); const [pet, setPet] = useState(null);

View File

@@ -5,6 +5,8 @@ import PetCard from "@/components/PetCard";
import { fetchAllPages } from "@/lib/fetchAllPages"; import { fetchAllPages } from "@/lib/fetchAllPages";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
//Adopt page
//Browse available pets with species/breed filters and search
const API_BASE = ""; const API_BASE = "";
const PAGE_SIZE = 10000; const PAGE_SIZE = 10000;
@@ -12,6 +14,8 @@ const inputCls = "px-4 py-[0.6rem] border-2 border-[#ddd] rounded-md text-base o
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 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]"; 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]";
//Pagination button
//Highlighted when it represents the current page
function PaginationBtn({ children, active, ...props }) { function PaginationBtn({ children, active, ...props }) {
return ( return (
<button <button
@@ -23,6 +27,7 @@ function PaginationBtn({ children, active, ...props }) {
); );
} }
//Main adopt page component
export default function AdoptPage() { export default function AdoptPage() {
const { selectedStoreId } = useCart(); const { selectedStoreId } = useCart();
@@ -35,6 +40,7 @@ export default function AdoptPage() {
const [selectedBreed, setSelectedBreed] = useState(""); const [selectedBreed, setSelectedBreed] = useState("");
const [speciesOptions, setSpeciesOptions] = useState([]); const [speciesOptions, setSpeciesOptions] = useState([]);
//Loads the species list from the first page of pets whenever the store changes
useEffect(() => { useEffect(() => {
setSelectedSpecies(""); setSelectedSpecies("");
const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) }); const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) });
@@ -68,6 +74,7 @@ export default function AdoptPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [query, selectedSpecies, selectedStoreId]); }, [query, selectedSpecies, selectedStoreId]);
//Builds the breed dropdown from the currently loaded pets
const breedOptions = useMemo( const breedOptions = useMemo(
() => [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) => () => [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }) a.localeCompare(b, undefined, { sensitivity: "base" })
@@ -84,7 +91,10 @@ export default function AdoptPage() {
const totalPages = Math.ceil(filteredPets.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredPets.length / ITEMS_PER_PAGE);
const displayedPets = filteredPets.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE); const displayedPets = filteredPets.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
//Submits search form
function handleSearch(e) { e.preventDefault(); setCurrentPage(0); setQuery(search.trim()); } function handleSearch(e) { e.preventDefault(); setCurrentPage(0); setQuery(search.trim()); }
//Resets all active filters and search text
function handleClearFilters() { setSearch(""); setQuery(""); setSelectedSpecies(""); setSelectedBreed(""); setCurrentPage(0); } function handleClearFilters() { setSearch(""); setQuery(""); setSelectedSpecies(""); setSelectedBreed(""); setCurrentPage(0); }
const hasActiveFilters = query || selectedSpecies || selectedBreed; const hasActiveFilters = query || selectedSpecies || selectedBreed;

View File

@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
const API_BASE = ""; const API_BASE = "";
//Checks if a filename looks like an image based on its extension
function isImageFilename(name) { function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || ""); return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
} }
//Shows an image inline or a download link for other file types
function AttachmentPreview({ url, name, token }) { function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null); const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name); const isImage = isImageFilename(name);
@@ -68,6 +70,7 @@ function AttachmentPreview({ url, name, token }) {
); );
} }
//Returns true when the screen width is below 640px
function useIsMobile() { function useIsMobile() {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
useEffect(() => { useEffect(() => {
@@ -79,6 +82,7 @@ function useIsMobile() {
return isMobile; return isMobile;
} }
//AI chat page with a conversation sidebar, supports switching to a human agent and sending file attachments
function AiChatPage() { function AiChatPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -137,6 +141,7 @@ function AiChatPage() {
if (nearBottom) area.scrollTop = area.scrollHeight; if (nearBottom) area.scrollTop = area.scrollHeight;
}, [botTyping]); }, [botTyping]);
//Loads all messages for a conversation and scrolls to the bottom
const fetchMessages = useCallback(async (convId) => { const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return; if (!token || !convId) return;
initialLoadDoneRef.current = false; initialLoadDoneRef.current = false;
@@ -162,6 +167,7 @@ function AiChatPage() {
} }
}, [token]); }, [token]);
//Fetches a single conversation's details and stores it
const fetchConversation = useCallback(async (convId) => { const fetchConversation = useCallback(async (convId) => {
if (!token || !convId) return null; if (!token || !convId) return null;
try { try {
@@ -194,6 +200,7 @@ function AiChatPage() {
} }
}, [token]); }, [token]);
//Connects the WebSocket and subscribes to new messages and conversation updates
const connectStomp = useCallback((convId) => { const connectStomp = useCallback((convId) => {
if (stompRef.current) { if (stompRef.current) {
stompRef.current.deactivate(); stompRef.current.deactivate();
@@ -301,6 +308,7 @@ function AiChatPage() {
}; };
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]); }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
//Decides whether to send a text message or an attachment
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
const text = input.trim(); const text = input.trim();
@@ -312,6 +320,7 @@ function AiChatPage() {
} }
} }
//Sends a plain text message and shows a bot typing indicator
async function handleSendText(text) { async function handleSendText(text) {
setInput(""); setInput("");
setSending(true); setSending(true);
@@ -356,6 +365,7 @@ function AiChatPage() {
} }
} }
//Uploads a file as an attachment, with an optional caption
async function handleSendAttachment(optionalText) { async function handleSendAttachment(optionalText) {
setSending(true); setSending(true);
setError(null); setError(null);
@@ -416,6 +426,7 @@ function AiChatPage() {
} }
} }
//Creates a new AI conversation and navigates to it
async function handleNewConversation() { async function handleNewConversation() {
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setError(null); setError(null);
@@ -457,6 +468,7 @@ function AiChatPage() {
router.replace(`/ai-chat?id=${convId}`, { scroll: false }); router.replace(`/ai-chat?id=${convId}`, { scroll: false });
} }
//Requests a human agent for the current conversation
async function handleSwitchToHuman() { async function handleSwitchToHuman() {
if (!conversation) return; if (!conversation) return;
try { try {
@@ -470,6 +482,7 @@ function AiChatPage() {
} }
} }
//Closes the current conversation so no more messages can be sent
async function handleCloseConversation() { async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return; if (!conversation || conversation.status === "CLOSED") return;
try { try {

View File

@@ -1,5 +1,7 @@
//Backend URL, falls back to localhost for local development
const BACKEND = process.env.BACKEND_URL || 'http://localhost:8080' const BACKEND = process.env.BACKEND_URL || 'http://localhost:8080'
//Forwards all incoming requests to the backend, preserving method, headers, and body
async function proxy(request, { params }) { async function proxy(request, { params }) {
const path = (await params).path.join('/') const path = (await params).path.join('/')
const { search } = new URL(request.url) const { search } = new URL(request.url)
@@ -10,6 +12,8 @@ async function proxy(request, { params }) {
headers.delete('origin') headers.delete('origin')
const init = { method: request.method, headers } const init = { method: request.method, headers }
//Only attach a body for requests that can have one
if (!['GET', 'HEAD'].includes(request.method)) { if (!['GET', 'HEAD'].includes(request.method)) {
init.body = request.body init.body = request.body
init.duplex = 'half' init.duplex = 'half'

View File

@@ -20,13 +20,31 @@ const SPECIES_BREEDS = {
Other: ["Other"], Other: ["Other"],
}; };
//Services that only apply to specific species, keyed by species name
const SPECIES_EXCLUSIVE_SERVICES = { const SPECIES_EXCLUSIVE_SERVICES = {
Bird: ["wing clipping", "beak and nail"], Bird: ["wing clipping", "beak and nail"],
Fish: ["aquarium health"], Fish: ["aquarium health"],
}; };
//Services that are banned for specific species, keyed by species name
const SPECIES_BANNED_SERVICES = {
Bird: ["teeth cleaning"],
};
//Filters out services that are exclusive to a different species, or banned for the selected species.
//When species is unknown, hides all species-exclusive and banned services to avoid invalid options appearing.
function getAvailableServices(services, species) { function getAvailableServices(services, species) {
if (!species) return services; const exclusiveKeywords = Object.values(SPECIES_EXCLUSIVE_SERVICES).flat();
const allBannedKeywords = Object.values(SPECIES_BANNED_SERVICES).flat();
if (!species) {
return services.filter((s) => {
const name = s.serviceName.toLowerCase();
return !exclusiveKeywords.some((kw) => name.includes(kw)) &&
!allBannedKeywords.some((kw) => name.includes(kw));
});
}
return services.filter((s) => { return services.filter((s) => {
const name = s.serviceName.toLowerCase(); const name = s.serviceName.toLowerCase();
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) { for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
@@ -34,6 +52,10 @@ function getAvailableServices(services, species) {
return false; return false;
} }
} }
const banned = SPECIES_BANNED_SERVICES[species] ?? [];
if (banned.some((kw) => name.includes(kw))) {
return false;
}
return true; return true;
}); });
} }
@@ -41,6 +63,7 @@ function getAvailableServices(services, species) {
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 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"];
//Custom calendar date picker that prevents selecting dates in the past
function DatePicker({ value, minDate, onChange }) { function DatePicker({ value, minDate, onChange }) {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@@ -137,6 +160,7 @@ const errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] 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"; 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";
//Modal dialog for quickly adding a new pet without leaving the appointments page
function AddPetModal({ token, onClose, onAdded }) { function AddPetModal({ token, onClose, onAdded }) {
const [petName, setPetName] = useState(""); const [petName, setPetName] = useState("");
const [species, setSpecies] = useState(""); const [species, setSpecies] = useState("");
@@ -219,6 +243,7 @@ function AddPetModal({ token, onClose, onAdded }) {
); );
} }
//Appointments page - book a service or adoption, and view past and active appointments
function AppointmentsPage() { function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -279,6 +304,18 @@ function AppointmentsPage() {
const [showPastAppts, setShowPastAppts] = useState(false); const [showPastAppts, setShowPastAppts] = useState(false);
const [showPastAdoptions, setShowPastAdoptions] = useState(false); const [showPastAdoptions, setShowPastAdoptions] = useState(false);
//Pagination state for each of the four history lists
const HISTORY_PAGE_SIZE = 5;
const [apptPage, setApptPage] = useState(0);
const [pastApptPage, setPastApptPage] = useState(0);
const [adoptionPage, setAdoptionPage] = useState(0);
const [pastAdoptionPage, setPastAdoptionPage] = useState(0);
//Reset appointment page to 0 when the search text changes
useEffect(() => { setApptPage(0); }, [apptSearch]);
//Reset adoption page to 0 when the search text changes
useEffect(() => { setAdoptionPage(0); }, [adoptionSearch]);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
useEffect(() => { useEffect(() => {
@@ -309,6 +346,7 @@ function AppointmentsPage() {
.finally(() => setAdoptionVerifyLoading(false)); .finally(() => setAdoptionVerifyLoading(false));
}, [adoptionMode, adoptionPetId, adoptionStoreId]); }, [adoptionMode, adoptionPetId, adoptionStoreId]);
//Loads the user's registered pets for the pet selector
const loadCustomerPets = useCallback(() => { const loadCustomerPets = useCallback(() => {
if (!token || !canBookAppointments) return; if (!token || !canBookAppointments) return;
fetch(`${API_BASE}/api/v1/my-pets`, { fetch(`${API_BASE}/api/v1/my-pets`, {
@@ -369,6 +407,7 @@ function AppointmentsPage() {
didPreselectRef.current = true; didPreselectRef.current = true;
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]); }, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
//Fetches the user's booked appointments
const loadAppointments = useCallback(() => { const loadAppointments = useCallback(() => {
if (!token) return; if (!token) return;
setLoadingAppointments(true); setLoadingAppointments(true);
@@ -381,6 +420,7 @@ function AppointmentsPage() {
.finally(() => setLoadingAppointments(false)); .finally(() => setLoadingAppointments(false));
}, [token]); }, [token]);
//Fetches the user's adoption requests
const loadAdoptions = useCallback(() => { const loadAdoptions = useCallback(() => {
if (!token) return; if (!token) return;
setLoadingAdoptions(true); setLoadingAdoptions(true);
@@ -398,6 +438,7 @@ function AppointmentsPage() {
loadAdoptions(); loadAdoptions();
}, [loadAppointments, loadAdoptions]); }, [loadAppointments, loadAdoptions]);
//Cancels an appointment after asking the user to confirm
async function handleCancelAppointment(appointmentId) { async function handleCancelAppointment(appointmentId) {
if (!confirm("Cancel this appointment?")) return; if (!confirm("Cancel this appointment?")) return;
setCancellingId(appointmentId); setCancellingId(appointmentId);
@@ -418,6 +459,7 @@ function AppointmentsPage() {
} }
} }
//Cancels an adoption request after asking the user to confirm
async function handleCancelAdoption(adoptionId) { async function handleCancelAdoption(adoptionId) {
if (!confirm("Cancel this adoption request?")) return; if (!confirm("Cancel this adoption request?")) return;
setCancellingId(adoptionId); setCancellingId(adoptionId);
@@ -494,6 +536,7 @@ function AppointmentsPage() {
setServiceId(newServiceId); setServiceId(newServiceId);
} }
//Selects a pet and clears the chosen service if it is not valid for that species
function handlePetSelect(petId) { function handlePetSelect(petId) {
const newPet = eligiblePets.find((p) => p.customerPetId === petId); const newPet = eligiblePets.find((p) => p.customerPetId === petId);
setSelectedPetIds([petId]); setSelectedPetIds([petId]);
@@ -507,6 +550,7 @@ function AppointmentsPage() {
} }
} }
//Converts a 24-hour time string to 12-hour AM/PM format
function formatTime(timeStr) { function formatTime(timeStr) {
const [h, m] = timeStr.split(":"); const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10); const hour = parseInt(h, 10);
@@ -524,6 +568,7 @@ function AppointmentsPage() {
? Boolean(employeeId && appointmentDate && adoptionVerified) ? Boolean(employeeId && appointmentDate && adoptionVerified)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; : storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
//Submits either a new appointment or an adoption request depending on the current mode
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -749,7 +794,7 @@ function AppointmentsPage() {
<option value="">Select a service...</option> <option value="">Select a service...</option>
{availableServices.map((s) => ( {availableServices.map((s) => (
<option key={s.serviceId} value={s.serviceId}> <option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min) {s.serviceName} - ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option> </option>
))} ))}
</select> </select>
@@ -834,6 +879,12 @@ function AppointmentsPage() {
const filteredActive = activeAppts.filter((a) => const filteredActive = activeAppts.filter((a) =>
!q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q)) !q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
); );
//Paginate active appointments
const apptTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const apptSlice = filteredActive.slice(apptPage * HISTORY_PAGE_SIZE, (apptPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past appointments
const pastApptTotalPages = Math.ceil(pastAppts.length / HISTORY_PAGE_SIZE);
const pastApptSlice = pastAppts.slice(pastApptPage * HISTORY_PAGE_SIZE, (pastApptPage + 1) * HISTORY_PAGE_SIZE);
return ( return (
<> <>
<input <input
@@ -846,8 +897,9 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? ( {filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{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="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{filteredActive.map((a) => ( {apptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]"> <div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span> <span className="font-semibold text-[#333]">{a.serviceName}</span>
@@ -875,15 +927,38 @@ function AppointmentsPage() {
</div> </div>
))} ))}
</div> </div>
{apptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p - 1)}
disabled={apptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {apptPage + 1} of {apptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p + 1)}
disabled={apptPage >= apptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
)}
</>
)} )}
{pastAppts.length > 0 && ( {pastAppts.length > 0 && (
<div className="mt-2"> <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)}> <button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAppts((v) => !v); setPastApptPage(0); }}>
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length}) {showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
</button> </button>
{showPastAppts && ( {showPastAppts && (
<>
<div className="flex flex-col gap-3 mt-3 opacity-75"> <div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAppts.map((a) => ( {pastApptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]"> <div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span> <span className="font-semibold text-[#333]">{a.serviceName}</span>
@@ -901,6 +976,28 @@ function AppointmentsPage() {
</div> </div>
))} ))}
</div> </div>
{pastApptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p - 1)}
disabled={pastApptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastApptPage + 1} of {pastApptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p + 1)}
disabled={pastApptPage >= pastApptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
)}
</>
)} )}
</div> </div>
)} )}
@@ -918,6 +1015,12 @@ function AppointmentsPage() {
const filteredActive = activeAdoptions.filter((a) => const filteredActive = activeAdoptions.filter((a) =>
!q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q)) !q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
); );
//Paginate active adoptions
const adoptionTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const adoptionSlice = filteredActive.slice(adoptionPage * HISTORY_PAGE_SIZE, (adoptionPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past adoptions
const pastAdoptionTotalPages = Math.ceil(pastAdoptions.length / HISTORY_PAGE_SIZE);
const pastAdoptionSlice = pastAdoptions.slice(pastAdoptionPage * HISTORY_PAGE_SIZE, (pastAdoptionPage + 1) * HISTORY_PAGE_SIZE);
return ( return (
<> <>
<input <input
@@ -930,8 +1033,9 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? ( {filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{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="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{filteredActive.map((a) => ( {adoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]"> <div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span> <span className="font-semibold text-[#333]">{a.petName}</span>
@@ -956,15 +1060,38 @@ function AppointmentsPage() {
</div> </div>
))} ))}
</div> </div>
{adoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p - 1)}
disabled={adoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {adoptionPage + 1} of {adoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p + 1)}
disabled={adoptionPage >= adoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
)}
</>
)} )}
{pastAdoptions.length > 0 && ( {pastAdoptions.length > 0 && (
<div className="mt-2"> <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)}> <button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAdoptions((v) => !v); setPastAdoptionPage(0); }}>
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length}) {showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
</button> </button>
{showPastAdoptions && ( {showPastAdoptions && (
<>
<div className="flex flex-col gap-3 mt-3 opacity-75"> <div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAdoptions.map((a) => ( {pastAdoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]"> <div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span> <span className="font-semibold text-[#333]">{a.petName}</span>
@@ -979,6 +1106,28 @@ function AppointmentsPage() {
</div> </div>
))} ))}
</div> </div>
{pastAdoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p - 1)}
disabled={pastAdoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastAdoptionPage + 1} of {pastAdoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p + 1)}
disabled={pastAdoptionPage >= pastAdoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
)}
</>
)} )}
</div> </div>
)} )}

View File

@@ -13,8 +13,10 @@ import {
} from "@stripe/react-stripe-js"; } from "@stripe/react-stripe-js";
import { apiCompleteCheckout } from "@/lib/cartApi"; import { apiCompleteCheckout } from "@/lib/cartApi";
//Initializes Stripe with the publishable key
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ""); const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
//Stripe payment form shown after the user clicks checkout
function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) { function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
const stripe = useStripe(); const stripe = useStripe();
const elements = useElements(); const elements = useElements();
@@ -22,6 +24,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
const [paying, setPaying] = useState(false); const [paying, setPaying] = useState(false);
const [payError, setPayError] = useState(null); const [payError, setPayError] = useState(null);
//Confirms the payment with Stripe, then tells the backend to complete the order
async function handlePay(e) { async function handlePay(e) {
e.preventDefault(); e.preventDefault();
if (!stripe || !elements) return; if (!stripe || !elements) return;
@@ -57,9 +60,6 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
<p className="text-[0.95rem] text-[#555] m-0"> <p className="text-[0.95rem] text-[#555] m-0">
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong> Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
</p> </p>
<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 /> <PaymentElement />
{payError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{payError}</p>} {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"> <div className="flex gap-3 mt-2">
@@ -79,6 +79,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
); );
} }
//Cart page - shows items, coupons, loyalty points, order summary, and checkout
export default function CartPage() { export default function CartPage() {
const { user, loading: authLoading, refreshUser } = useAuth(); const { user, loading: authLoading, refreshUser } = useAuth();
const { const {
@@ -114,12 +115,14 @@ export default function CartPage() {
const [localQuantities, setLocalQuantities] = useState({}); const [localQuantities, setLocalQuantities] = useState({});
//Redirect unauthenticated users to login
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
router.push("/login"); router.push("/login");
} }
}, [authLoading, user, router]); }, [authLoading, user, router]);
//Sync local quantity inputs whenever the cart updates from the server
useEffect(() => { useEffect(() => {
if (cart?.items) { if (cart?.items) {
const map = {}; const map = {};
@@ -129,12 +132,14 @@ export default function CartPage() {
setOptimisticPointsApplied(null); setOptimisticPointsApplied(null);
}, [cart]); }, [cart]);
//Cancel any leftover pending checkout if the page loads without a client secret
useEffect(() => { useEffect(() => {
if (cart?.checkoutPending && !clientSecret) { if (cart?.checkoutPending && !clientSecret) {
cancelCheckout().catch(() => {}); cancelCheckout().catch(() => {});
} }
}, [cart?.checkoutPending, clientSecret, cancelCheckout]); }, [cart?.checkoutPending, clientSecret, cancelCheckout]);
//Updates item quantity and rolls back the change if the request fails
async function handleQuantityChange(cartItemId, newQty) { async function handleQuantityChange(cartItemId, newQty) {
if (newQty < 1) return; if (newQty < 1) return;
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty })); setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty }));
@@ -158,6 +163,7 @@ export default function CartPage() {
catch {} catch {}
} }
//Applies the typed coupon code and shows the discount type and amount
async function handleApplyCoupon() { async function handleApplyCoupon() {
if (!couponInput.trim()) return; if (!couponInput.trim()) return;
setCouponLoading(true); setCouponLoading(true);
@@ -212,6 +218,8 @@ export default function CartPage() {
} }
} }
//Starts checkout
//Either gets a Stripe client secret for payment or marks the order complete directly
async function handleCheckout() { async function handleCheckout() {
if (!cart?.items?.length) return; if (!cart?.items?.length) return;
setCheckoutLoading(true); setCheckoutLoading(true);
@@ -497,7 +505,7 @@ export default function CartPage() {
)} )}
{clientSecret && ( {clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}> <Elements stripe={stripePromise} options={{ clientSecret, wallets: { link: "never" } }}>
<PaymentForm <PaymentForm
clientSecret={clientSecret} clientSecret={clientSecret}
totalAmount={checkoutTotal ?? cart.totalAmount} totalAmount={checkoutTotal ?? cart.totalAmount}

View File

@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
const API_BASE = ""; const API_BASE = "";
//Checks if a filename looks like an image based on its extension
function isImageFilename(name) { function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || ""); return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
} }
//Shows an image inline or a download link for other file types
function AttachmentPreview({ url, name, token }) { function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null); const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name); const isImage = isImageFilename(name);
@@ -68,6 +70,7 @@ function AttachmentPreview({ url, name, token }) {
); );
} }
//Live support chat page with a conversation sidebar and real-time messaging via WebSocket
function ChatPage() { function ChatPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -113,6 +116,7 @@ function ChatPage() {
if (nearBottom) area.scrollTop = area.scrollHeight; if (nearBottom) area.scrollTop = area.scrollHeight;
}, [messages]); }, [messages]);
//Loads all messages for a conversation and scrolls to the bottom
const fetchMessages = useCallback(async (convId) => { const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return; if (!token || !convId) return;
initialLoadDoneRef.current = false; initialLoadDoneRef.current = false;
@@ -145,6 +149,7 @@ function ChatPage() {
} }
}, [token]); }, [token]);
//Fetches a single conversation's details and stores it in state
const fetchConversation = useCallback(async (convId) => { const fetchConversation = useCallback(async (convId) => {
if (!token || !convId) return null; if (!token || !convId) return null;
try { try {
@@ -183,6 +188,7 @@ function ChatPage() {
} }
}, [token]); }, [token]);
//Connects the WebSocket and subscribes to new messages and conversation updates
const connectStomp = useCallback((convId) => { const connectStomp = useCallback((convId) => {
if (stompRef.current) { if (stompRef.current) {
stompRef.current.deactivate(); stompRef.current.deactivate();
@@ -268,6 +274,7 @@ function ChatPage() {
}; };
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]); }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
//Decides whether to send a text message or an attachment
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
const text = input.trim(); const text = input.trim();
@@ -279,6 +286,7 @@ function ChatPage() {
} }
} }
//Sends a plain text message to the support agent
async function handleSendText(text) { async function handleSendText(text) {
setInput(""); setInput("");
setSending(true); setSending(true);
@@ -321,6 +329,7 @@ function ChatPage() {
} }
} }
//Uploads a file as an attachment, with an optional caption
async function handleSendAttachment(optionalText) { async function handleSendAttachment(optionalText) {
setSending(true); setSending(true);
setError(null); setError(null);
@@ -381,6 +390,7 @@ function ChatPage() {
} }
} }
//Creates a new live support conversation and requests a human agent straight away
async function handleNewConversation() { async function handleNewConversation() {
setError(null); setError(null);
setLoadingConv(true); setLoadingConv(true);
@@ -429,6 +439,7 @@ function ChatPage() {
router.replace(`/chat?id=${convId}`, { scroll: false }); router.replace(`/chat?id=${convId}`, { scroll: false });
} }
//Closes the current conversation so no more messages can be sent
async function handleCloseConversation() { async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return; if (!conversation || conversation.status === "CLOSED") return;
try { try {

View File

@@ -7,6 +7,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
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 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"; 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";
//Returns the image path for a store, guessing from the store name if no image is set
function getStoreImage(store) { function getStoreImage(store) {
if (store.imageUrl) return store.imageUrl; if (store.imageUrl) return store.imageUrl;
const name = store.storeName?.toLowerCase() ?? ""; const name = store.storeName?.toLowerCase() ?? "";
@@ -16,6 +17,7 @@ function getStoreImage(store) {
return "/images/pet-placeholder.png"; return "/images/pet-placeholder.png";
} }
//Contact page with a message form on the left and store location cards on the right
export default function ContactPage() { export default function ContactPage() {
const { token } = useAuth(); const { token } = useAuth();
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
@@ -28,6 +30,7 @@ export default function ContactPage() {
const [sendError, setSendError] = useState(null); const [sendError, setSendError] = useState(null);
const [sendSuccess, setSendSuccess] = useState(false); const [sendSuccess, setSendSuccess] = useState(false);
//Loads all store locations when the page first opens
useEffect(() => { useEffect(() => {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" }); const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`) fetch(`/api/v1/stores?${params}`)
@@ -40,6 +43,7 @@ export default function ContactPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
//Submits the contact form to the backend
async function handleSend(e) { async function handleSend(e) {
e.preventDefault(); e.preventDefault();
if (!token) { if (!token) {

View File

@@ -8,6 +8,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
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 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"; 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";
//Forgot password page, sends a reset link to the user's email
function ForgotPasswordPage() { function ForgotPasswordPage() {
const [usernameOrEmail, setUsernameOrEmail] = useState(""); const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -15,6 +16,7 @@ function ForgotPasswordPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
//Sends the forgot password request and hides the form on success
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -4,11 +4,14 @@ import DisplayNav from "@/components/Navigation";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import ClientProviders from "@/components/ClientProviders"; import ClientProviders from "@/components/ClientProviders";
//Page title and description
export const metadata = { export const metadata = {
title: "Leon's Pet Store", title: "Leon's Pet Store",
description: "Generated by create next app", description: "Generated by create next app",
}; };
//Root layout
//Wraps every page with the nav, footer, and all context providers
export default function RootLayout({children}) { export default function RootLayout({children}) {
return ( return (
<html lang="en"> <html lang="en">

View File

@@ -10,12 +10,15 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
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 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"; 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";
//Returns the redirect path after login, blocking open redirects to external or auth pages
function resolveNextPath(candidate) { function resolveNextPath(candidate) {
if (!candidate || !candidate.startsWith("/")) return "/"; if (!candidate || !candidate.startsWith("/")) return "/";
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) return "/"; if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) return "/";
return candidate; return candidate;
} }
//Login page
//Username and password form, redirects to the page the user was trying to visit
function LoginPage() { function LoginPage() {
const {login} = useAuth(); const {login} = useAuth();
const router = useRouter(); const router = useRouter();
@@ -26,6 +29,7 @@ function LoginPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
//Submits the login form and redirects on success
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
//Images used in the auto-scrolling slideshow at the top of the home page
const slideshowImages = [ const slideshowImages = [
{id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"}, {id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
{id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"}, {id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"},
@@ -11,15 +12,18 @@ const slideshowImages = [
{id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"}, {id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
]; ];
//Image cards linking to the three main sections of the site
const navImages = [ const navImages = [
{id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"}, {id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"},
{id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"}, {id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"},
{id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"}, {id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"},
]; ];
//Home page - slideshow, nav image links, and about us section
export default function Home() { export default function Home() {
const [currentSlide, setCurrentSlide] = useState(0); const [currentSlide, setCurrentSlide] = useState(0);
//Advances the slideshow every 7.5 seconds
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { setCurrentSlide((prev) => (prev + 1) % slideshowImages.length); }, 7500); const timer = setInterval(() => { setCurrentSlide((prev) => (prev + 1) % slideshowImages.length); }, 7500);
return () => clearInterval(timer); return () => clearInterval(timer);

View File

@@ -7,6 +7,7 @@ import ProductProfile from "@/components/ProductProfile";
const API_BASE = ""; const API_BASE = "";
//Product detail page, fetches a single product by ID and passes it to ProductProfile
export default function ProductDetailPage() { export default function ProductDetailPage() {
const { id } = useParams(); const { id } = useParams();
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);

View File

@@ -4,6 +4,8 @@ import { useState, useEffect } from "react";
import ProductCard from "@/components/ProductCard"; import ProductCard from "@/components/ProductCard";
import { fetchAllPages } from "@/lib/fetchAllPages"; import { fetchAllPages } from "@/lib/fetchAllPages";
//Products page
//Searchable grid of all store products with pagination
const API_BASE = ""; const API_BASE = "";
export default function ProductsPage() { export default function ProductsPage() {
@@ -34,6 +36,7 @@ export default function ProductsPage() {
const totalPages = Math.ceil(products.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(products.length / ITEMS_PER_PAGE);
const displayedProducts = products.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE); const displayedProducts = products.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
//Submits the search form
function handleSearch(e) { function handleSearch(e) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);

View File

@@ -6,6 +6,7 @@ import { useAuth } from "@/context/AuthContext";
const API_BASE = ""; const API_BASE = "";
//Species and breed options for the add/edit pet form
const SPECIES_BREEDS = { const SPECIES_BREEDS = {
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"], Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"], Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
@@ -19,12 +20,13 @@ const SPECIES_BREEDS = {
}; };
const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[#444]"; 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 inputCls = "px-[0.85rem] py-[0.6rem] bg-white border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const selectCls = `custom-select ${inputCls} bg-white cursor-pointer`; const 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 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 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"; const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Profile page - shows user info, edit form, avatar upload, owned pets, and order history
export default function ProfilePage() { export default function ProfilePage() {
const {user, token, loading, logout, refreshUser} = useAuth(); const {user, token, loading, logout, refreshUser} = useAuth();
const router = useRouter(); const router = useRouter();
@@ -49,6 +51,7 @@ export default function ProfilePage() {
const [profileSuccess, setProfileSuccess] = useState(null); const [profileSuccess, setProfileSuccess] = useState(null);
const [avatarSubmitting, setAvatarSubmitting] = useState(false); const [avatarSubmitting, setAvatarSubmitting] = useState(false);
//Revokes all blob URLs created for pet images to free up memory
const clearPetImageObjectUrls = useCallback(() => { const clearPetImageObjectUrls = useCallback(() => {
for (const objectUrl of petImageObjectUrlsRef.current) { for (const objectUrl of petImageObjectUrlsRef.current) {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
@@ -73,6 +76,7 @@ export default function ProfilePage() {
}); });
}, [user]); }, [user]);
//Fetches the user's owned pets and resolves their images into blob URLs
const loadPets = useCallback(async () => { const loadPets = useCallback(async () => {
if (!token) return; if (!token) return;
setLoadingPets(true); setLoadingPets(true);
@@ -128,6 +132,7 @@ export default function ProfilePage() {
}; };
}, [clearPetImageObjectUrls]); }, [clearPetImageObjectUrls]);
//Fetches the user's recent order history
const loadOrders = useCallback(async () => { const loadOrders = useCallback(async () => {
if (!token) return; if (!token) return;
setLoadingOrders(true); setLoadingOrders(true);
@@ -176,11 +181,13 @@ export default function ProfilePage() {
}; };
}, [user?.avatarUrl, token]); }, [user?.avatarUrl, token]);
//Logs out and sends the user to the home page
function handleLogout() { function handleLogout() {
logout(); logout();
router.push("/"); router.push("/");
} }
//Saves changes to the user's name, email, phone, or password
async function handleProfileSubmit(e) { async function handleProfileSubmit(e) {
e.preventDefault(); e.preventDefault();
setProfileError(null); setProfileError(null);
@@ -232,6 +239,7 @@ export default function ProfilePage() {
} }
} }
//Uploads a new avatar image for the user
async function handleAvatarUpload(file) { async function handleAvatarUpload(file) {
if (!file) return; if (!file) return;
@@ -266,6 +274,7 @@ export default function ProfilePage() {
} }
} }
//Removes the user's avatar
async function handleAvatarDelete() { async function handleAvatarDelete() {
setAvatarSubmitting(true); setAvatarSubmitting(true);
setProfileError(null); setProfileError(null);
@@ -295,6 +304,7 @@ export default function ProfilePage() {
} }
} }
//Opens the add pet form with blank fields
function openAddForm() { function openAddForm() {
setEditingPet(null); setEditingPet(null);
setPetName(""); setPetName("");
@@ -305,6 +315,7 @@ export default function ProfilePage() {
setShowForm(true); setShowForm(true);
} }
//Opens the edit pet form pre-filled with the selected pet's details
function openEditForm(pet) { function openEditForm(pet) {
setEditingPet(pet); setEditingPet(pet);
setPetName(pet.petName); setPetName(pet.petName);
@@ -321,6 +332,7 @@ export default function ProfilePage() {
setPetError(null); setPetError(null);
} }
//Creates a new pet or saves edits to an existing one
async function handlePetSubmit(e) { async function handlePetSubmit(e) {
e.preventDefault(); e.preventDefault();
setPetError(null); setPetError(null);
@@ -358,6 +370,7 @@ export default function ProfilePage() {
} }
} }
//Confirms with the user then removes the pet profile
async function handleDeletePet(id) { async function handleDeletePet(id) {
if (!confirm("Remove this pet profile?")) return; if (!confirm("Remove this pet profile?")) return;
@@ -378,6 +391,7 @@ export default function ProfilePage() {
} }
} }
//Uploads a photo for a specific pet profile
async function handleImageUpload(petId, file) { async function handleImageUpload(petId, file) {
const formData = new FormData(); const formData = new FormData();
formData.append("image", file); formData.append("image", file);

View File

@@ -20,6 +20,8 @@ function resolveNextPath(candidate) {
return candidate; return candidate;
} }
//Registration page
//Collects user details, checks passwords match, then creates an account
function RegisterPage() { function RegisterPage() {
const {register} = useAuth(); const {register} = useAuth();
const router = useRouter(); const router = useRouter();
@@ -38,10 +40,12 @@ function RegisterPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
//Updates a single field in the form state
function handleChange(e) { function handleChange(e) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
} }
//Validates passwords match then submits the registration form
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -9,6 +9,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
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 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"; 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";
//Reset password page, reads the token from the URL and lets the user set a new password
function ResetPasswordPage() { function ResetPasswordPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -36,6 +37,7 @@ function ResetPasswordPage() {
); );
} }
//Validates passwords match, submits the reset, then redirects to login after 3 seconds
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -5,6 +5,7 @@ import { CartProvider } from "@/context/CartContext";
import { ChatWidgetProvider } from "@/context/ChatWidgetContext"; import { ChatWidgetProvider } from "@/context/ChatWidgetContext";
import FloatingChat from "@/components/FloatingChat"; import FloatingChat from "@/components/FloatingChat";
//Wraps the app in all client-side context providers and adds the floating chat button
export default function ClientProviders({ children }) { export default function ClientProviders({ children }) {
return ( return (
<AuthProvider> <AuthProvider>

View File

@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useChatWidget } from "@/context/ChatWidgetContext"; import { useChatWidget } from "@/context/ChatWidgetContext";
//Floating chat button and popup window - handles AI chat, live support, and conversation history
export default function FloatingChat() { export default function FloatingChat() {
const pathname = usePathname(); const pathname = usePathname();
const { user, token } = useAuth(); const { user, token } = useAuth();
@@ -25,6 +26,7 @@ export default function FloatingChat() {
const prevAiLengthRef = useRef(0); const prevAiLengthRef = useRef(0);
const prevLiveLengthRef = useRef(0); const prevLiveLengthRef = useRef(0);
//Scrolls to the bottom when new messages arrive, but only if already near the bottom
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const aiGrew = aiMessages.length > prevAiLengthRef.current; const aiGrew = aiMessages.length > prevAiLengthRef.current;
@@ -41,12 +43,13 @@ const prevLiveLengthRef = useRef(0);
if (view === "history" && token && isOpen) loadConversations(token); if (view === "history" && token && isOpen) loadConversations(token);
}, [view, token, isOpen, loadConversations]); }, [view, token, isOpen, loadConversations]);
// Hide widget on dedicated chat pages //Don't show the widget on the full chat pages since they have their own UI
if (pathname === "/ai-chat" || pathname === "/chat") return null; if (pathname === "/ai-chat" || pathname === "/chat") return null;
const openConvCount = conversations.filter((c) => c.status === "OPEN").length; const openConvCount = conversations.filter((c) => c.status === "OPEN").length;
const isLiveClosed = activeConv?.status === "CLOSED"; const isLiveClosed = activeConv?.status === "CLOSED";
//Sends the typed message to whichever chat is currently active
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
const text = input.trim(); const text = input.trim();
@@ -358,7 +361,7 @@ const prevLiveLengthRef = useRef(0);
); );
} }
// Styles //Inline style objects for the floating chat widget
const s = { const s = {
fab: { fab: {
position: "fixed", bottom: 24, right: 24, position: "fixed", bottom: 24, right: 24,

View File

@@ -1,8 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
//Shared CSS class for footer links
const linkCls = "text-[#2f2f2f] no-underline text-[0.95rem] opacity-85 transition-opacity hover:opacity-100 hover:underline"; const linkCls = "text-[#2f2f2f] no-underline text-[0.95rem] opacity-85 transition-opacity hover:opacity-100 hover:underline";
//Site footer with quick links, company links, and contact info
export default function Footer() { export default function Footer() {
return ( return (
<footer className="bg-[#e68672] text-[#2f2f2f] mt-16 rounded-t-[10px]"> <footer className="bg-[#e68672] text-[#2f2f2f] mt-16 rounded-t-[10px]">

View File

@@ -7,11 +7,13 @@ import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
//Shared CSS class strings for nav links and buttons
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 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 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 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"; 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";
//Cart icon with a red badge showing the number of items
function CartIcon({ itemCount, onClick }) { function CartIcon({ itemCount, onClick }) {
return ( return (
<Link href="/cart" className={`${cartBtnCls} group`} aria-label="Cart" onClick={onClick}> <Link href="/cart" className={`${cartBtnCls} group`} aria-label="Cart" onClick={onClick}>
@@ -26,6 +28,7 @@ function CartIcon({ itemCount, onClick }) {
); );
} }
//Top navigation bar - desktop links on the left, store selector and auth on the right, hamburger menu on mobile
export default function DisplayNav() { export default function DisplayNav() {
const { user, logout, loading } = useAuth(); const { user, logout, loading } = useAuth();
const { itemCount, selectedStoreId, setStoreId } = useCart(); const { itemCount, selectedStoreId, setStoreId } = useCart();
@@ -33,6 +36,7 @@ export default function DisplayNav() {
const [stores, setStores] = useState([]); const [stores, setStores] = useState([]);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
//Loads the store list for the store selector dropdown
useEffect(() => { useEffect(() => {
fetch("/api/v1/stores?size=100") fetch("/api/v1/stores?size=100")
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
@@ -40,6 +44,7 @@ export default function DisplayNav() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
//Logs out and sends the user to the home page
function handleLogout() { function handleLogout() {
logout(); logout();
router.push("/"); router.push("/");
@@ -48,6 +53,7 @@ export default function DisplayNav() {
function closeMenu() { setMenuOpen(false); } function closeMenu() { setMenuOpen(false); }
//Store selector dropdown, shared between desktop and mobile layouts
const storeSelect = (extraCls = "") => stores.length > 0 && ( const storeSelect = (extraCls = "") => stores.length > 0 && (
<select <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}`} 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}`}

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getStatusClass } from "@/components/petUtils"; import { getStatusClass } from "@/components/petUtils";
//Card shown in the adopt grid, links to the pet's detail page
export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl}) { export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl}) {
return ( return (
<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"> <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">

View File

@@ -5,6 +5,7 @@ const fieldRowCls = "flex items-center px-5 py-[0.85rem] border-b border-[#eee]
const fieldLabelCls = "w-[140px] text-[0.9rem] font-semibold text-[#888] uppercase tracking-[0.04em] shrink-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 fieldValueCls = "text-base text-[#333]";
//Full detail view for a single pet, shown on the adopt detail page
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) { export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) {
return ( return (
<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="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">
@@ -31,22 +32,22 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
<div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden"> <div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden">
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Species</span> <span className={fieldLabelCls}>Species</span>
<span className={fieldValueCls}>{petSpecies ?? ""}</span> <span className={fieldValueCls}>{petSpecies ?? "-"}</span>
</div> </div>
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Breed</span> <span className={fieldLabelCls}>Breed</span>
<span className={fieldValueCls}>{petBreed ?? ""}</span> <span className={fieldValueCls}>{petBreed ?? "-"}</span>
</div> </div>
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Age</span> <span className={fieldLabelCls}>Age</span>
<span className={fieldValueCls}> <span className={fieldValueCls}>
{petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : ""} {petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "-"}
</span> </span>
</div> </div>
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Adoption Fee</span> <span className={fieldLabelCls}>Adoption Fee</span>
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}> <span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
{petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : ""} {petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "-"}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -6,8 +6,10 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
//Shared CSS class for the quantity plus and minus buttons
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"; 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";
//Card shown in the products grid, includes quantity selector and add to cart button
export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) { export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) {
const { user } = useAuth(); const { user } = useAuth();
const { addItem, selectedStoreId } = useCart(); const { addItem, selectedStoreId } = useCart();
@@ -16,6 +18,7 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice,
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [feedback, setFeedback] = useState(null); const [feedback, setFeedback] = useState(null);
//Adds the selected quantity to the cart, redirects to login if not logged in
async function handleAddToCart(e) { async function handleAddToCart(e) {
e.preventDefault(); e.preventDefault();
if (!user) { router.push("/login"); return; } if (!user) { router.push("/login"); return; }

View File

@@ -9,6 +9,7 @@ const fieldLabelCls = "w-[140px] text-[0.9rem] font-semibold text-[#888] upperca
const fieldValueCls = "text-base text-[#333]"; 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"; 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";
//Full detail view for a single product, shown on the product detail page
export default function ProductProfile({ prodId, prodName, categoryName, prodDesc, prodPrice, imageUrl }) { export default function ProductProfile({ prodId, prodName, categoryName, prodDesc, prodPrice, imageUrl }) {
const { user } = useAuth(); const { user } = useAuth();
const { addItem, selectedStoreId } = useCart(); const { addItem, selectedStoreId } = useCart();
@@ -17,8 +18,10 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [feedback, setFeedback] = useState(null); const [feedback, setFeedback] = useState(null);
//Increments or decrements quantity, minimum of 1
function changeQty(delta) { setQuantity((q) => Math.max(1, q + delta)); } function changeQty(delta) { setQuantity((q) => Math.max(1, q + delta)); }
//Adds the chosen quantity to the cart and shows a success or error message
async function handleAddToCart() { async function handleAddToCart() {
setAdding(true); setAdding(true);
setFeedback(null); setFeedback(null);
@@ -56,17 +59,17 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
<div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden"> <div className="flex flex-col border border-[#eee] rounded-[10px] overflow-hidden">
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Category</span> <span className={fieldLabelCls}>Category</span>
<span className={fieldValueCls}>{categoryName ?? ""}</span> <span className={fieldValueCls}>{categoryName ?? "-"}</span>
</div> </div>
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Price</span> <span className={fieldLabelCls}>Price</span>
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}> <span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
{prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : ""} {prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "-"}
</span> </span>
</div> </div>
<div className={fieldRowCls}> <div className={fieldRowCls}>
<span className={fieldLabelCls}>Description</span> <span className={fieldLabelCls}>Description</span>
<span className={fieldValueCls}>{prodDesc ?? ""}</span> <span className={fieldValueCls}>{prodDesc ?? "-"}</span>
</div> </div>
</div> </div>

View File

@@ -13,6 +13,7 @@ export const SPECIES_EMOJI = {
guinea: "🐹", guinea: "🐹",
}; };
//Returns an emoji for a given species name, falls back to a paw print
export function getSpeciesEmoji(species) { export function getSpeciesEmoji(species) {
if (!species) { if (!species) {
@@ -31,6 +32,7 @@ export function getSpeciesEmoji(species) {
return "🐾"; return "🐾";
} }
//Returns the CSS class name for a pet's status badge
export function getStatusClass(status) { export function getStatusClass(status) {
if (!status) { if (!status) {
return ""; return "";

View File

@@ -2,10 +2,14 @@
import { createContext, useContext, useState, useEffect, useCallback } from "react"; import { createContext, useContext, useState, useEffect, useCallback } from "react";
//Auth context
//Stores the logged in user, token, and auth actions
const AuthContext = createContext(null); const AuthContext = createContext(null);
//Key used to save the token in localStorage
const TOKEN_KEY = "auth_token"; const TOKEN_KEY = "auth_token";
//Fetches the current user from the backend using the stored token
async function fetchCurrentUser(token) { async function fetchCurrentUser(token) {
const res = await fetch("/api/v1/auth/me", { const res = await fetch("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@@ -14,11 +18,13 @@ async function fetchCurrentUser(token) {
return res.json(); return res.json();
} }
//Provides auth state to all child components
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [token, setToken] = useState(null); const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
//Re-fetches the user info using the current or a newly provided token
const refreshUser = useCallback(async (providedToken) => { const refreshUser = useCallback(async (providedToken) => {
const activeToken = providedToken ?? token; const activeToken = providedToken ?? token;
if (!activeToken) { if (!activeToken) {
@@ -41,6 +47,7 @@ export function AuthProvider({ children }) {
return userInfo; return userInfo;
}, [token]); }, [token]);
//On first load, check if a token was saved and try to restore the session
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(TOKEN_KEY); const stored = localStorage.getItem(TOKEN_KEY);
if (!stored) { if (!stored) {
@@ -57,6 +64,7 @@ export function AuthProvider({ children }) {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [refreshUser]); }, [refreshUser]);
//Logs the user in and saves the token
const login = useCallback(async (username, password) => { const login = useCallback(async (username, password) => {
const res = await fetch("/api/v1/auth/login", { const res = await fetch("/api/v1/auth/login", {
method: "POST", method: "POST",
@@ -84,6 +92,7 @@ export function AuthProvider({ children }) {
return userInfo; return userInfo;
}, [refreshUser]); }, [refreshUser]);
//Creates a new account and logs the user in right away
const register = useCallback(async ({ username, password, email, firstName, lastName, phone }) => { const register = useCallback(async ({ username, password, email, firstName, lastName, phone }) => {
const res = await fetch("/api/v1/auth/register", { const res = await fetch("/api/v1/auth/register", {
method: "POST", method: "POST",
@@ -118,6 +127,7 @@ export function AuthProvider({ children }) {
return userInfo; return userInfo;
}, [refreshUser]); }, [refreshUser]);
//Clears the token and user from memory and localStorage
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
setToken(null); setToken(null);
@@ -130,6 +140,7 @@ export function AuthProvider({ children }) {
); );
} }
//Hook to access auth state, must be used inside an AuthProvider
export function useAuth() { export function useAuth() {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);
if (!ctx) { if (!ctx) {

View File

@@ -15,10 +15,14 @@ import {
apiCancelCheckout, apiCancelCheckout,
} from "@/lib/cartApi"; } from "@/lib/cartApi";
//Cart context
//Holds the user's cart and all cart actions
const CartContext = createContext(null); const CartContext = createContext(null);
//Key used to save the selected store in localStorage
const STORE_KEY = "selected_store_id"; const STORE_KEY = "selected_store_id";
//Provides cart state to all child components
export function CartProvider({ children }) { export function CartProvider({ children }) {
const { user, token } = useAuth(); const { user, token } = useAuth();
const [cart, setCart] = useState(null); const [cart, setCart] = useState(null);
@@ -26,6 +30,7 @@ export function CartProvider({ children }) {
const [cartLoading, setCartLoading] = useState(false); const [cartLoading, setCartLoading] = useState(false);
const [cartError, setCartError] = useState(null); const [cartError, setCartError] = useState(null);
//Saves the selected store in state and localStorage
const setStoreId = useCallback((id) => { const setStoreId = useCallback((id) => {
const parsed = id ? Number(id) : null; const parsed = id ? Number(id) : null;
setSelectedStoreIdState(parsed); setSelectedStoreIdState(parsed);
@@ -51,6 +56,7 @@ export function CartProvider({ children }) {
} }
}, [user, setStoreId]); }, [user, setStoreId]);
//Fetches the latest cart from the backend
const refreshCart = useCallback(async () => { const refreshCart = useCallback(async () => {
if (!token || !selectedStoreId) { if (!token || !selectedStoreId) {
setCart(null); setCart(null);
@@ -173,6 +179,7 @@ export function CartProvider({ children }) {
[token, selectedStoreId, refreshCart] [token, selectedStoreId, refreshCart]
); );
//Total number of items across all cart rows, used for the cart badge in the nav
const itemCount = cart?.items?.reduce((sum, i) => sum + i.quantity, 0) ?? 0; const itemCount = cart?.items?.reduce((sum, i) => sum + i.quantity, 0) ?? 0;
return ( return (
@@ -201,6 +208,8 @@ export function CartProvider({ children }) {
); );
} }
//Hook to access cart state
//Must be used inside a CartProvider
export function useCart() { export function useCart() {
const ctx = useContext(CartContext); const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within a CartProvider"); if (!ctx) throw new Error("useCart must be used within a CartProvider");

View File

@@ -4,15 +4,18 @@ import { createContext, useContext, useState, useRef, useCallback, useEffect } f
import { createStompClient } from "@/lib/chatSocket"; import { createStompClient } from "@/lib/chatSocket";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
//Chat widget context
//Manages both the AI chat and the live support chat
const ChatWidgetContext = createContext(null); const ChatWidgetContext = createContext(null);
const API_BASE = ""; const API_BASE = "";
//Provides chat state and actions for the floating chat widget
export function ChatWidgetProvider({ children }) { export function ChatWidgetProvider({ children }) {
const { user } = useAuth(); const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState("ai"); // "ai" | "history" | "live" const [view, setView] = useState("ai"); // "ai" | "history" | "live"
// AI chat //AI chat messages, loaded from localStorage so they survive page refreshes
const [aiMessages, setAiMessages] = useState(() => { const [aiMessages, setAiMessages] = useState(() => {
try { try {
const saved = localStorage.getItem("fc_aiMessages"); const saved = localStorage.getItem("fc_aiMessages");
@@ -22,15 +25,16 @@ export function ChatWidgetProvider({ children }) {
const [aiSending, setAiSending] = useState(false); const [aiSending, setAiSending] = useState(false);
const [aiError, setAiError] = useState(null); const [aiError, setAiError] = useState(null);
// Persist aiMessages to localStorage //Save AI messages to localStorage whenever they change
useEffect(() => { useEffect(() => {
localStorage.setItem("fc_aiMessages", JSON.stringify(aiMessages)); localStorage.setItem("fc_aiMessages", JSON.stringify(aiMessages));
}, [aiMessages]); }, [aiMessages]);
// Keep a ref so sendAiMessage stays stable (no stale-closure over messages) //Ref to the latest messages so the send function always sees current state
const aiMessagesRef = useRef(aiMessages); const aiMessagesRef = useRef(aiMessages);
useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]); useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]);
//Sends a message to the AI and appends the response to the chat
const sendAiMessage = useCallback(async (text, token) => { const sendAiMessage = useCallback(async (text, token) => {
if (!text.trim() || !token) return; if (!text.trim() || !token) return;
const userMsg = { role: "user", content: text, id: Date.now() }; const userMsg = { role: "user", content: text, id: Date.now() };
@@ -54,7 +58,7 @@ export function ChatWidgetProvider({ children }) {
} }
}, []); }, []);
// Live chat //Live chat state
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false); const [convsLoading, setConvsLoading] = useState(false);
const [activeConvId, setActiveConvId] = useState(null); const [activeConvId, setActiveConvId] = useState(null);
@@ -67,6 +71,7 @@ export function ChatWidgetProvider({ children }) {
const activeConvIdRef = useRef(null); const activeConvIdRef = useRef(null);
const tokenRef = useRef(null); const tokenRef = useRef(null);
//Disconnects the WebSocket if it is active
const disconnectStomp = useCallback(() => { const disconnectStomp = useCallback(() => {
if (stompRef.current) { if (stompRef.current) {
stompRef.current.deactivate(); stompRef.current.deactivate();
@@ -74,6 +79,7 @@ export function ChatWidgetProvider({ children }) {
} }
}, []); }, []);
//Clears all chat state when the user logs out or switches accounts
const prevUserIdRef = useRef(user?.id); const prevUserIdRef = useRef(user?.id);
useEffect(() => { useEffect(() => {
const currentId = user?.id ?? null; const currentId = user?.id ?? null;
@@ -92,6 +98,7 @@ export function ChatWidgetProvider({ children }) {
} }
}, [user?.id, disconnectStomp]); }, [user?.id, disconnectStomp]);
//Subscribes to incoming messages and conversation updates for a given chat
const subscribeToConversation = useCallback((client, convId) => { const subscribeToConversation = useCallback((client, convId) => {
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
try { try {
@@ -172,6 +179,7 @@ export function ChatWidgetProvider({ children }) {
} }
}, [liveSending]); }, [liveSending]);
//Creates a new live chat conversation and requests a human agent
const startLiveChat = useCallback(async (token) => { const startLiveChat = useCallback(async (token) => {
if (!token || switchingToHuman) return; if (!token || switchingToHuman) return;
setSwitchingToHuman(true); setSwitchingToHuman(true);
@@ -228,6 +236,7 @@ export function ChatWidgetProvider({ children }) {
); );
} }
//Hook to access chat widget state - must be used inside a ChatWidgetProvider
export function useChatWidget() { export function useChatWidget() {
const ctx = useContext(ChatWidgetContext); const ctx = useContext(ChatWidgetContext);
if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider"); if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider");

View File

@@ -1,5 +1,7 @@
//Base path for all cart API calls
const BASE = "/api/v1/cart"; const BASE = "/api/v1/cart";
//Returns the standard headers needed for authenticated requests
function authHeaders(token) { function authHeaders(token) {
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -7,6 +9,7 @@ function authHeaders(token) {
}; };
} }
//Parses the response and throws an error if the request failed
async function handleResponse(res) { async function handleResponse(res) {
if (res.status === 204) return null; if (res.status === 204) return null;
const data = await res.json(); const data = await res.json();

View File

@@ -1,7 +1,9 @@
import { Client } from "@stomp/stompjs"; import { Client } from "@stomp/stompjs";
//Backend URL for the WebSocket connection, empty string means same origin
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
//Creates a STOMP client that connects to the chat WebSocket with the user's token
export function createStompClient(token) { export function createStompClient(token) {
return new Client({ return new Client({
webSocketFactory: () => { webSocketFactory: () => {

View File

@@ -1,3 +1,4 @@
//Fetches every page from a paginated API and returns all items in one array
export async function fetchAllPages(urlBuilder) { export async function fetchAllPages(urlBuilder) {
const items = []; const items = [];
let page = 0; let page = 0;