Comments, appointments adjustments, fixed some issues
This commit is contained in:
@@ -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'),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -455,6 +466,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 {
|
||||||
@@ -468,6 +480,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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -425,6 +435,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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user