@@ -1,3 +1,5 @@
|
||||
//About page
|
||||
//Three info cards covering what the store does, its focus, and how to visit
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="bg-gradient-to-b from-[#f9f9f9] to-white">
|
||||
|
||||
@@ -7,6 +7,8 @@ import PetProfile from "@/components/PetProfile";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
//Pet detail page
|
||||
//Fetches a single pet by ID and passes it to PetProfile
|
||||
export default function PetDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [pet, setPet] = useState(null);
|
||||
|
||||
@@ -5,6 +5,8 @@ import PetCard from "@/components/PetCard";
|
||||
import { fetchAllPages } from "@/lib/fetchAllPages";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
//Adopt page
|
||||
//Browse available pets with species/breed filters and search
|
||||
const API_BASE = "";
|
||||
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 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 }) {
|
||||
return (
|
||||
<button
|
||||
@@ -23,6 +27,7 @@ function PaginationBtn({ children, active, ...props }) {
|
||||
);
|
||||
}
|
||||
|
||||
//Main adopt page component
|
||||
export default function AdoptPage() {
|
||||
const { selectedStoreId } = useCart();
|
||||
|
||||
@@ -35,6 +40,7 @@ export default function AdoptPage() {
|
||||
const [selectedBreed, setSelectedBreed] = useState("");
|
||||
const [speciesOptions, setSpeciesOptions] = useState([]);
|
||||
|
||||
//Loads the species list from the first page of pets whenever the store changes
|
||||
useEffect(() => {
|
||||
setSelectedSpecies("");
|
||||
const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) });
|
||||
@@ -68,6 +74,7 @@ export default function AdoptPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [query, selectedSpecies, selectedStoreId]);
|
||||
|
||||
//Builds the breed dropdown from the currently loaded pets
|
||||
const breedOptions = useMemo(
|
||||
() => [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" })
|
||||
@@ -84,7 +91,10 @@ export default function AdoptPage() {
|
||||
const totalPages = Math.ceil(filteredPets.length / 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()); }
|
||||
|
||||
//Resets all active filters and search text
|
||||
function handleClearFilters() { setSearch(""); setQuery(""); setSelectedSpecies(""); setSelectedBreed(""); setCurrentPage(0); }
|
||||
const hasActiveFilters = query || selectedSpecies || selectedBreed;
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
//Checks if a filename looks like an image based on its extension
|
||||
function isImageFilename(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 }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const isImage = isImageFilename(name);
|
||||
@@ -68,6 +70,7 @@ function AttachmentPreview({ url, name, token }) {
|
||||
);
|
||||
}
|
||||
|
||||
//Returns true when the screen width is below 640px
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -79,6 +82,7 @@ function useIsMobile() {
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
//AI chat page with a conversation sidebar, supports switching to a human agent and sending file attachments
|
||||
function AiChatPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -137,6 +141,7 @@ function AiChatPage() {
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [botTyping]);
|
||||
|
||||
//Loads all messages for a conversation and scrolls to the bottom
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
@@ -162,6 +167,7 @@ function AiChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
//Fetches a single conversation's details and stores it
|
||||
const fetchConversation = useCallback(async (convId) => {
|
||||
if (!token || !convId) return null;
|
||||
try {
|
||||
@@ -194,6 +200,7 @@ function AiChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
//Connects the WebSocket and subscribes to new messages and conversation updates
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
@@ -301,6 +308,7 @@ function AiChatPage() {
|
||||
};
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
|
||||
|
||||
//Decides whether to send a text message or an attachment
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
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) {
|
||||
setInput("");
|
||||
setSending(true);
|
||||
@@ -356,6 +365,7 @@ function AiChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Uploads a file as an attachment, with an optional caption
|
||||
async function handleSendAttachment(optionalText) {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
@@ -416,6 +426,7 @@ function AiChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Creates a new AI conversation and navigates to it
|
||||
async function handleNewConversation() {
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setError(null);
|
||||
@@ -457,6 +468,7 @@ function AiChatPage() {
|
||||
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
//Requests a human agent for the current conversation
|
||||
async function handleSwitchToHuman() {
|
||||
if (!conversation) return;
|
||||
try {
|
||||
@@ -470,6 +482,7 @@ function AiChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Closes the current conversation so no more messages can be sent
|
||||
async function handleCloseConversation() {
|
||||
if (!conversation || conversation.status === "CLOSED") return;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//Backend URL, falls back to localhost for local development
|
||||
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 }) {
|
||||
const path = (await params).path.join('/')
|
||||
const { search } = new URL(request.url)
|
||||
@@ -10,6 +12,8 @@ async function proxy(request, { params }) {
|
||||
headers.delete('origin')
|
||||
|
||||
const init = { method: request.method, headers }
|
||||
|
||||
//Only attach a body for requests that can have one
|
||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||
init.body = request.body
|
||||
init.duplex = 'half'
|
||||
|
||||
@@ -20,13 +20,31 @@ const SPECIES_BREEDS = {
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
//Services that only apply to specific species, keyed by species name
|
||||
const SPECIES_EXCLUSIVE_SERVICES = {
|
||||
Bird: ["wing clipping", "beak and nail"],
|
||||
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) {
|
||||
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) => {
|
||||
const name = s.serviceName.toLowerCase();
|
||||
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
|
||||
@@ -34,6 +52,10 @@ function getAvailableServices(services, species) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const banned = SPECIES_BANNED_SERVICES[species] ?? [];
|
||||
if (banned.some((kw) => name.includes(kw))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -41,6 +63,7 @@ function getAvailableServices(services, species) {
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
//Custom calendar date picker that prevents selecting dates in the past
|
||||
function DatePicker({ value, minDate, onChange }) {
|
||||
const today = new Date();
|
||||
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 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 }) {
|
||||
const [petName, setPetName] = 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() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -279,6 +304,18 @@ function AppointmentsPage() {
|
||||
const [showPastAppts, setShowPastAppts] = 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";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -309,6 +346,7 @@ function AppointmentsPage() {
|
||||
.finally(() => setAdoptionVerifyLoading(false));
|
||||
}, [adoptionMode, adoptionPetId, adoptionStoreId]);
|
||||
|
||||
//Loads the user's registered pets for the pet selector
|
||||
const loadCustomerPets = useCallback(() => {
|
||||
if (!token || !canBookAppointments) return;
|
||||
fetch(`${API_BASE}/api/v1/my-pets`, {
|
||||
@@ -369,6 +407,7 @@ function AppointmentsPage() {
|
||||
didPreselectRef.current = true;
|
||||
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
|
||||
|
||||
//Fetches the user's booked appointments
|
||||
const loadAppointments = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoadingAppointments(true);
|
||||
@@ -381,6 +420,7 @@ function AppointmentsPage() {
|
||||
.finally(() => setLoadingAppointments(false));
|
||||
}, [token]);
|
||||
|
||||
//Fetches the user's adoption requests
|
||||
const loadAdoptions = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoadingAdoptions(true);
|
||||
@@ -398,6 +438,7 @@ function AppointmentsPage() {
|
||||
loadAdoptions();
|
||||
}, [loadAppointments, loadAdoptions]);
|
||||
|
||||
//Cancels an appointment after asking the user to confirm
|
||||
async function handleCancelAppointment(appointmentId) {
|
||||
if (!confirm("Cancel this appointment?")) return;
|
||||
setCancellingId(appointmentId);
|
||||
@@ -418,6 +459,7 @@ function AppointmentsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Cancels an adoption request after asking the user to confirm
|
||||
async function handleCancelAdoption(adoptionId) {
|
||||
if (!confirm("Cancel this adoption request?")) return;
|
||||
setCancellingId(adoptionId);
|
||||
@@ -494,6 +536,7 @@ function AppointmentsPage() {
|
||||
setServiceId(newServiceId);
|
||||
}
|
||||
|
||||
//Selects a pet and clears the chosen service if it is not valid for that species
|
||||
function handlePetSelect(petId) {
|
||||
const newPet = eligiblePets.find((p) => p.customerPetId === petId);
|
||||
setSelectedPetIds([petId]);
|
||||
@@ -507,6 +550,7 @@ function AppointmentsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Converts a 24-hour time string to 12-hour AM/PM format
|
||||
function formatTime(timeStr) {
|
||||
const [h, m] = timeStr.split(":");
|
||||
const hour = parseInt(h, 10);
|
||||
@@ -524,6 +568,7 @@ function AppointmentsPage() {
|
||||
? Boolean(employeeId && appointmentDate && adoptionVerified)
|
||||
: 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) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -749,7 +794,7 @@ function AppointmentsPage() {
|
||||
<option value="">Select a service...</option>
|
||||
{availableServices.map((s) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
@@ -834,6 +879,12 @@ function AppointmentsPage() {
|
||||
const filteredActive = activeAppts.filter((a) =>
|
||||
!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 (
|
||||
<>
|
||||
<input
|
||||
@@ -846,61 +897,107 @@ function AppointmentsPage() {
|
||||
{filteredActive.length === 0 ? (
|
||||
<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">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{apptSlice.map((a) => (
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{pastAppts.length > 0 && (
|
||||
<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})
|
||||
</button>
|
||||
{showPastAppts && (
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAppts.map((a) => (
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
<>
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastApptSlice.map((a) => (
|
||||
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.serviceName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</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) =>
|
||||
!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 (
|
||||
<>
|
||||
<input
|
||||
@@ -930,55 +1033,101 @@ function AppointmentsPage() {
|
||||
{filteredActive.length === 0 ? (
|
||||
<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">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{adoptionSlice.map((a) => (
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{pastAdoptions.length > 0 && (
|
||||
<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})
|
||||
</button>
|
||||
{showPastAdoptions && (
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAdoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
<>
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAdoptionSlice.map((a) => (
|
||||
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-[#333]">{a.petName}</span>
|
||||
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</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";
|
||||
import { apiCompleteCheckout } from "@/lib/cartApi";
|
||||
|
||||
//Initializes Stripe with the 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 }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
@@ -22,6 +24,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [payError, setPayError] = useState(null);
|
||||
|
||||
//Confirms the payment with Stripe, then tells the backend to complete the order
|
||||
async function handlePay(e) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) return;
|
||||
@@ -57,9 +60,6 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
||||
<p className="text-[0.95rem] text-[#555] m-0">
|
||||
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
|
||||
</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 />
|
||||
{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">
|
||||
@@ -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() {
|
||||
const { user, loading: authLoading, refreshUser } = useAuth();
|
||||
const {
|
||||
@@ -114,12 +115,14 @@ export default function CartPage() {
|
||||
|
||||
const [localQuantities, setLocalQuantities] = useState({});
|
||||
|
||||
//Redirect unauthenticated users to login
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
//Sync local quantity inputs whenever the cart updates from the server
|
||||
useEffect(() => {
|
||||
if (cart?.items) {
|
||||
const map = {};
|
||||
@@ -129,12 +132,14 @@ export default function CartPage() {
|
||||
setOptimisticPointsApplied(null);
|
||||
}, [cart]);
|
||||
|
||||
//Cancel any leftover pending checkout if the page loads without a client secret
|
||||
useEffect(() => {
|
||||
if (cart?.checkoutPending && !clientSecret) {
|
||||
cancelCheckout().catch(() => {});
|
||||
}
|
||||
}, [cart?.checkoutPending, clientSecret, cancelCheckout]);
|
||||
|
||||
//Updates item quantity and rolls back the change if the request fails
|
||||
async function handleQuantityChange(cartItemId, newQty) {
|
||||
if (newQty < 1) return;
|
||||
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty }));
|
||||
@@ -158,6 +163,7 @@ export default function CartPage() {
|
||||
catch {}
|
||||
}
|
||||
|
||||
//Applies the typed coupon code and shows the discount type and amount
|
||||
async function handleApplyCoupon() {
|
||||
if (!couponInput.trim()) return;
|
||||
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() {
|
||||
if (!cart?.items?.length) return;
|
||||
setCheckoutLoading(true);
|
||||
@@ -497,7 +505,7 @@ export default function CartPage() {
|
||||
)}
|
||||
|
||||
{clientSecret && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<Elements stripe={stripePromise} options={{ clientSecret, wallets: { link: "never" } }}>
|
||||
<PaymentForm
|
||||
clientSecret={clientSecret}
|
||||
totalAmount={checkoutTotal ?? cart.totalAmount}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
//Checks if a filename looks like an image based on its extension
|
||||
function isImageFilename(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 }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
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() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -113,6 +116,7 @@ function ChatPage() {
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
//Loads all messages for a conversation and scrolls to the bottom
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
@@ -145,6 +149,7 @@ function ChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
//Fetches a single conversation's details and stores it in state
|
||||
const fetchConversation = useCallback(async (convId) => {
|
||||
if (!token || !convId) return null;
|
||||
try {
|
||||
@@ -183,6 +188,7 @@ function ChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
//Connects the WebSocket and subscribes to new messages and conversation updates
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
@@ -268,6 +274,7 @@ function ChatPage() {
|
||||
};
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
|
||||
|
||||
//Decides whether to send a text message or an attachment
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -279,6 +286,7 @@ function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Sends a plain text message to the support agent
|
||||
async function handleSendText(text) {
|
||||
setInput("");
|
||||
setSending(true);
|
||||
@@ -321,6 +329,7 @@ function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Uploads a file as an attachment, with an optional caption
|
||||
async function handleSendAttachment(optionalText) {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
@@ -381,6 +390,7 @@ function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Creates a new live support conversation and requests a human agent straight away
|
||||
async function handleNewConversation() {
|
||||
setError(null);
|
||||
setLoadingConv(true);
|
||||
@@ -429,6 +439,7 @@ function ChatPage() {
|
||||
router.replace(`/chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
//Closes the current conversation so no more messages can be sent
|
||||
async function handleCloseConversation() {
|
||||
if (!conversation || conversation.status === "CLOSED") return;
|
||||
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 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) {
|
||||
if (store.imageUrl) return store.imageUrl;
|
||||
const name = store.storeName?.toLowerCase() ?? "";
|
||||
@@ -16,6 +17,7 @@ function getStoreImage(store) {
|
||||
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() {
|
||||
const { token } = useAuth();
|
||||
const [locations, setLocations] = useState([]);
|
||||
@@ -28,6 +30,7 @@ export default function ContactPage() {
|
||||
const [sendError, setSendError] = useState(null);
|
||||
const [sendSuccess, setSendSuccess] = useState(false);
|
||||
|
||||
//Loads all store locations when the page first opens
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
|
||||
fetch(`/api/v1/stores?${params}`)
|
||||
@@ -40,6 +43,7 @@ export default function ContactPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
//Submits the contact form to the backend
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
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 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() {
|
||||
const [usernameOrEmail, setUsernameOrEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -15,6 +16,7 @@ function ForgotPasswordPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
//Sends the forgot password request and hides the form on success
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
@@ -4,11 +4,14 @@ import DisplayNav from "@/components/Navigation";
|
||||
import Footer from "@/components/Footer";
|
||||
import ClientProviders from "@/components/ClientProviders";
|
||||
|
||||
//Page title and description
|
||||
export const metadata = {
|
||||
title: "Leon's Pet Store",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
//Root layout
|
||||
//Wraps every page with the nav, footer, and all context providers
|
||||
export default function RootLayout({children}) {
|
||||
return (
|
||||
<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 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) {
|
||||
if (!candidate || !candidate.startsWith("/")) return "/";
|
||||
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) return "/";
|
||||
return candidate;
|
||||
}
|
||||
|
||||
//Login page
|
||||
//Username and password form, redirects to the page the user was trying to visit
|
||||
function LoginPage() {
|
||||
const {login} = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -26,6 +29,7 @@ function LoginPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
//Submits the login form and redirects on success
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
//Images used in the auto-scrolling slideshow at the top of the home page
|
||||
const slideshowImages = [
|
||||
{id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
|
||||
{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"},
|
||||
];
|
||||
|
||||
//Image cards linking to the three main sections of the site
|
||||
const navImages = [
|
||||
{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-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() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
//Advances the slideshow every 7.5 seconds
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => { setCurrentSlide((prev) => (prev + 1) % slideshowImages.length); }, 7500);
|
||||
return () => clearInterval(timer);
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProductProfile from "@/components/ProductProfile";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
//Product detail page, fetches a single product by ID and passes it to ProductProfile
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [product, setProduct] = useState(null);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect } from "react";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import { fetchAllPages } from "@/lib/fetchAllPages";
|
||||
|
||||
//Products page
|
||||
//Searchable grid of all store products with pagination
|
||||
const API_BASE = "";
|
||||
|
||||
export default function ProductsPage() {
|
||||
@@ -34,6 +36,7 @@ export default function ProductsPage() {
|
||||
const totalPages = Math.ceil(products.length / ITEMS_PER_PAGE);
|
||||
const displayedProducts = products.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
|
||||
|
||||
//Submits the search form
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
//Species and breed options for the add/edit pet form
|
||||
const SPECIES_BREEDS = {
|
||||
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
|
||||
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
|
||||
@@ -19,12 +20,13 @@ const SPECIES_BREEDS = {
|
||||
};
|
||||
|
||||
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 errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem]";
|
||||
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]";
|
||||
const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
|
||||
|
||||
//Profile page - shows user info, edit form, avatar upload, owned pets, and order history
|
||||
export default function ProfilePage() {
|
||||
const {user, token, loading, logout, refreshUser} = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -49,6 +51,7 @@ export default function ProfilePage() {
|
||||
const [profileSuccess, setProfileSuccess] = useState(null);
|
||||
const [avatarSubmitting, setAvatarSubmitting] = useState(false);
|
||||
|
||||
//Revokes all blob URLs created for pet images to free up memory
|
||||
const clearPetImageObjectUrls = useCallback(() => {
|
||||
for (const objectUrl of petImageObjectUrlsRef.current) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
@@ -73,6 +76,7 @@ export default function ProfilePage() {
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
//Fetches the user's owned pets and resolves their images into blob URLs
|
||||
const loadPets = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingPets(true);
|
||||
@@ -128,6 +132,7 @@ export default function ProfilePage() {
|
||||
};
|
||||
}, [clearPetImageObjectUrls]);
|
||||
|
||||
//Fetches the user's recent order history
|
||||
const loadOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingOrders(true);
|
||||
@@ -176,11 +181,13 @@ export default function ProfilePage() {
|
||||
};
|
||||
}, [user?.avatarUrl, token]);
|
||||
|
||||
//Logs out and sends the user to the home page
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
//Saves changes to the user's name, email, phone, or password
|
||||
async function handleProfileSubmit(e) {
|
||||
e.preventDefault();
|
||||
setProfileError(null);
|
||||
@@ -232,6 +239,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Uploads a new avatar image for the user
|
||||
async function handleAvatarUpload(file) {
|
||||
if (!file) return;
|
||||
|
||||
@@ -266,6 +274,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Removes the user's avatar
|
||||
async function handleAvatarDelete() {
|
||||
setAvatarSubmitting(true);
|
||||
setProfileError(null);
|
||||
@@ -295,6 +304,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Opens the add pet form with blank fields
|
||||
function openAddForm() {
|
||||
setEditingPet(null);
|
||||
setPetName("");
|
||||
@@ -305,6 +315,7 @@ export default function ProfilePage() {
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
//Opens the edit pet form pre-filled with the selected pet's details
|
||||
function openEditForm(pet) {
|
||||
setEditingPet(pet);
|
||||
setPetName(pet.petName);
|
||||
@@ -321,6 +332,7 @@ export default function ProfilePage() {
|
||||
setPetError(null);
|
||||
}
|
||||
|
||||
//Creates a new pet or saves edits to an existing one
|
||||
async function handlePetSubmit(e) {
|
||||
e.preventDefault();
|
||||
setPetError(null);
|
||||
@@ -358,6 +370,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
//Confirms with the user then removes the pet profile
|
||||
async function handleDeletePet(id) {
|
||||
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) {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
@@ -20,6 +20,8 @@ function resolveNextPath(candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
//Registration page
|
||||
//Collects user details, checks passwords match, then creates an account
|
||||
function RegisterPage() {
|
||||
const {register} = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -38,10 +40,12 @@ function RegisterPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
//Updates a single field in the form state
|
||||
function handleChange(e) {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
}
|
||||
|
||||
//Validates passwords match then submits the registration form
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
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 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() {
|
||||
const router = useRouter();
|
||||
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) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CartProvider } from "@/context/CartContext";
|
||||
import { ChatWidgetProvider } from "@/context/ChatWidgetContext";
|
||||
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 }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useChatWidget } from "@/context/ChatWidgetContext";
|
||||
|
||||
//Floating chat button and popup window - handles AI chat, live support, and conversation history
|
||||
export default function FloatingChat() {
|
||||
const pathname = usePathname();
|
||||
const { user, token } = useAuth();
|
||||
@@ -25,6 +26,7 @@ export default function FloatingChat() {
|
||||
const prevAiLengthRef = useRef(0);
|
||||
const prevLiveLengthRef = useRef(0);
|
||||
|
||||
//Scrolls to the bottom when new messages arrive, but only if already near the bottom
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const aiGrew = aiMessages.length > prevAiLengthRef.current;
|
||||
@@ -41,12 +43,13 @@ const prevLiveLengthRef = useRef(0);
|
||||
if (view === "history" && token && isOpen) loadConversations(token);
|
||||
}, [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;
|
||||
|
||||
const openConvCount = conversations.filter((c) => c.status === "OPEN").length;
|
||||
const isLiveClosed = activeConv?.status === "CLOSED";
|
||||
|
||||
//Sends the typed message to whichever chat is currently active
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -358,7 +361,7 @@ const prevLiveLengthRef = useRef(0);
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
//Inline style objects for the floating chat widget
|
||||
const s = {
|
||||
fab: {
|
||||
position: "fixed", bottom: 24, right: 24,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
//Site footer with quick links, company links, and contact info
|
||||
export default function Footer() {
|
||||
return (
|
||||
<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 { 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 navLinkCls = "text-[#2f2f2f] no-underline text-[1.05rem] font-semibold px-4 py-2 rounded-md transition-all duration-[250ms] hover:bg-white/25";
|
||||
const cartBtnCls = "relative inline-flex items-center text-[1.4rem] no-underline mr-2 px-[0.4rem] py-[0.2rem] rounded-md transition-colors hover:bg-white/20";
|
||||
const cartBadgeCls = "absolute -top-1 -right-1.5 bg-[#e53935] text-white rounded-full text-[0.65rem] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-[3px] leading-none";
|
||||
|
||||
//Cart icon with a red badge showing the number of items
|
||||
function CartIcon({ itemCount, onClick }) {
|
||||
return (
|
||||
<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() {
|
||||
const { user, logout, loading } = useAuth();
|
||||
const { itemCount, selectedStoreId, setStoreId } = useCart();
|
||||
@@ -33,6 +36,7 @@ export default function DisplayNav() {
|
||||
const [stores, setStores] = useState([]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
//Loads the store list for the store selector dropdown
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/stores?size=100")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
@@ -40,6 +44,7 @@ export default function DisplayNav() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
//Logs out and sends the user to the home page
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/");
|
||||
@@ -48,6 +53,7 @@ export default function DisplayNav() {
|
||||
|
||||
function closeMenu() { setMenuOpen(false); }
|
||||
|
||||
//Store selector dropdown, shared between desktop and mobile layouts
|
||||
const storeSelect = (extraCls = "") => stores.length > 0 && (
|
||||
<select
|
||||
className={`bg-[rgba(47,47,47,0.1)] text-[#2f2f2f] border border-[rgba(47,47,47,0.35)] rounded-md px-[0.6rem] py-[0.3rem] text-[0.9rem] cursor-pointer outline-none transition-colors hover:bg-[rgba(47,47,47,0.2)] ${extraCls}`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
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}) {
|
||||
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">
|
||||
|
||||
@@ -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 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 }) {
|
||||
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">
|
||||
@@ -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={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Species</span>
|
||||
<span className={fieldValueCls}>{petSpecies ?? "—"}</span>
|
||||
<span className={fieldValueCls}>{petSpecies ?? "-"}</span>
|
||||
</div>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Breed</span>
|
||||
<span className={fieldValueCls}>{petBreed ?? "—"}</span>
|
||||
<span className={fieldValueCls}>{petBreed ?? "-"}</span>
|
||||
</div>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Age</span>
|
||||
<span className={fieldValueCls}>
|
||||
{petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "—"}
|
||||
{petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Adoption Fee</span>
|
||||
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
|
||||
{petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "—"}
|
||||
{petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
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";
|
||||
|
||||
//Card shown in the products grid, includes quantity selector and add to cart button
|
||||
export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) {
|
||||
const { user } = useAuth();
|
||||
const { addItem, selectedStoreId } = useCart();
|
||||
@@ -16,6 +18,7 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice,
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
//Adds the selected quantity to the cart, redirects to login if not logged in
|
||||
async function handleAddToCart(e) {
|
||||
e.preventDefault();
|
||||
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 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 }) {
|
||||
const { user } = useAuth();
|
||||
const { addItem, selectedStoreId } = useCart();
|
||||
@@ -17,8 +18,10 @@ export default function ProductProfile({ prodId, prodName, categoryName, prodDes
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
//Increments or decrements quantity, minimum of 1
|
||||
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() {
|
||||
setAdding(true);
|
||||
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={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Category</span>
|
||||
<span className={fieldValueCls}>{categoryName ?? "—"}</span>
|
||||
<span className={fieldValueCls}>{categoryName ?? "-"}</span>
|
||||
</div>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Price</span>
|
||||
<span className={`${fieldValueCls} font-bold text-[#1a7a3c] text-[1.1rem]`}>
|
||||
{prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "—"}
|
||||
{prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={fieldRowCls}>
|
||||
<span className={fieldLabelCls}>Description</span>
|
||||
<span className={fieldValueCls}>{prodDesc ?? "—"}</span>
|
||||
<span className={fieldValueCls}>{prodDesc ?? "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SPECIES_EMOJI = {
|
||||
guinea: "🐹",
|
||||
};
|
||||
|
||||
//Returns an emoji for a given species name, falls back to a paw print
|
||||
export function getSpeciesEmoji(species) {
|
||||
if (!species) {
|
||||
|
||||
@@ -31,6 +32,7 @@ export function getSpeciesEmoji(species) {
|
||||
return "🐾";
|
||||
}
|
||||
|
||||
//Returns the CSS class name for a pet's status badge
|
||||
export function getStatusClass(status) {
|
||||
if (!status) {
|
||||
return "";
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
|
||||
//Auth context
|
||||
//Stores the logged in user, token, and auth actions
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
//Key used to save the token in localStorage
|
||||
const TOKEN_KEY = "auth_token";
|
||||
|
||||
//Fetches the current user from the backend using the stored token
|
||||
async function fetchCurrentUser(token) {
|
||||
const res = await fetch("/api/v1/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -14,11 +18,13 @@ async function fetchCurrentUser(token) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
//Provides auth state to all child components
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
//Re-fetches the user info using the current or a newly provided token
|
||||
const refreshUser = useCallback(async (providedToken) => {
|
||||
const activeToken = providedToken ?? token;
|
||||
if (!activeToken) {
|
||||
@@ -41,6 +47,7 @@ export function AuthProvider({ children }) {
|
||||
return userInfo;
|
||||
}, [token]);
|
||||
|
||||
//On first load, check if a token was saved and try to restore the session
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(TOKEN_KEY);
|
||||
if (!stored) {
|
||||
@@ -57,6 +64,7 @@ export function AuthProvider({ children }) {
|
||||
.finally(() => setLoading(false));
|
||||
}, [refreshUser]);
|
||||
|
||||
//Logs the user in and saves the token
|
||||
const login = useCallback(async (username, password) => {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
@@ -84,6 +92,7 @@ export function AuthProvider({ children }) {
|
||||
return userInfo;
|
||||
}, [refreshUser]);
|
||||
|
||||
//Creates a new account and logs the user in right away
|
||||
const register = useCallback(async ({ username, password, email, firstName, lastName, phone }) => {
|
||||
const res = await fetch("/api/v1/auth/register", {
|
||||
method: "POST",
|
||||
@@ -118,6 +127,7 @@ export function AuthProvider({ children }) {
|
||||
return userInfo;
|
||||
}, [refreshUser]);
|
||||
|
||||
//Clears the token and user from memory and localStorage
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
@@ -130,6 +140,7 @@ export function AuthProvider({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
//Hook to access auth state, must be used inside an AuthProvider
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
|
||||
@@ -15,10 +15,14 @@ import {
|
||||
apiCancelCheckout,
|
||||
} from "@/lib/cartApi";
|
||||
|
||||
//Cart context
|
||||
//Holds the user's cart and all cart actions
|
||||
const CartContext = createContext(null);
|
||||
|
||||
//Key used to save the selected store in localStorage
|
||||
const STORE_KEY = "selected_store_id";
|
||||
|
||||
//Provides cart state to all child components
|
||||
export function CartProvider({ children }) {
|
||||
const { user, token } = useAuth();
|
||||
const [cart, setCart] = useState(null);
|
||||
@@ -26,6 +30,7 @@ export function CartProvider({ children }) {
|
||||
const [cartLoading, setCartLoading] = useState(false);
|
||||
const [cartError, setCartError] = useState(null);
|
||||
|
||||
//Saves the selected store in state and localStorage
|
||||
const setStoreId = useCallback((id) => {
|
||||
const parsed = id ? Number(id) : null;
|
||||
setSelectedStoreIdState(parsed);
|
||||
@@ -51,6 +56,7 @@ export function CartProvider({ children }) {
|
||||
}
|
||||
}, [user, setStoreId]);
|
||||
|
||||
//Fetches the latest cart from the backend
|
||||
const refreshCart = useCallback(async () => {
|
||||
if (!token || !selectedStoreId) {
|
||||
setCart(null);
|
||||
@@ -173,6 +179,7 @@ export function CartProvider({ children }) {
|
||||
[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;
|
||||
|
||||
return (
|
||||
@@ -201,6 +208,8 @@ export function CartProvider({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
//Hook to access cart state
|
||||
//Must be used inside a CartProvider
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
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 { useAuth } from "@/context/AuthContext";
|
||||
|
||||
//Chat widget context
|
||||
//Manages both the AI chat and the live support chat
|
||||
const ChatWidgetContext = createContext(null);
|
||||
const API_BASE = "";
|
||||
|
||||
//Provides chat state and actions for the floating chat widget
|
||||
export function ChatWidgetProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("fc_aiMessages");
|
||||
@@ -22,15 +25,16 @@ export function ChatWidgetProvider({ children }) {
|
||||
const [aiSending, setAiSending] = useState(false);
|
||||
const [aiError, setAiError] = useState(null);
|
||||
|
||||
// Persist aiMessages to localStorage
|
||||
//Save AI messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
localStorage.setItem("fc_aiMessages", JSON.stringify(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);
|
||||
useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]);
|
||||
|
||||
//Sends a message to the AI and appends the response to the chat
|
||||
const sendAiMessage = useCallback(async (text, token) => {
|
||||
if (!text.trim() || !token) return;
|
||||
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 [convsLoading, setConvsLoading] = useState(false);
|
||||
const [activeConvId, setActiveConvId] = useState(null);
|
||||
@@ -67,6 +71,7 @@ export function ChatWidgetProvider({ children }) {
|
||||
const activeConvIdRef = useRef(null);
|
||||
const tokenRef = useRef(null);
|
||||
|
||||
//Disconnects the WebSocket if it is active
|
||||
const disconnectStomp = useCallback(() => {
|
||||
if (stompRef.current) {
|
||||
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);
|
||||
useEffect(() => {
|
||||
const currentId = user?.id ?? null;
|
||||
@@ -92,6 +98,7 @@ export function ChatWidgetProvider({ children }) {
|
||||
}
|
||||
}, [user?.id, disconnectStomp]);
|
||||
|
||||
//Subscribes to incoming messages and conversation updates for a given chat
|
||||
const subscribeToConversation = useCallback((client, convId) => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
@@ -172,6 +179,7 @@ export function ChatWidgetProvider({ children }) {
|
||||
}
|
||||
}, [liveSending]);
|
||||
|
||||
//Creates a new live chat conversation and requests a human agent
|
||||
const startLiveChat = useCallback(async (token) => {
|
||||
if (!token || switchingToHuman) return;
|
||||
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() {
|
||||
const ctx = useContext(ChatWidgetContext);
|
||||
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";
|
||||
|
||||
//Returns the standard headers needed for authenticated requests
|
||||
function authHeaders(token) {
|
||||
return {
|
||||
"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) {
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 || "";
|
||||
|
||||
//Creates a STOMP client that connects to the chat WebSocket with the user's token
|
||||
export function createStompClient(token) {
|
||||
return new Client({
|
||||
webSocketFactory: () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//Fetches every page from a paginated API and returns all items in one array
|
||||
export async function fetchAllPages(urlBuilder) {
|
||||
const items = [];
|
||||
let page = 0;
|
||||
|
||||
Reference in New Issue
Block a user