diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql index 3134a995..f43fc3f8 100644 --- a/backend/src/main/resources/db/migration/V2__seed_data.sql +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -199,6 +199,7 @@ INSERT INTO service_species (serviceId, species) VALUES (1, 'Dog'), (1, 'Cat'), (1, 'Rabbit'), +(1, 'Bird'), (2, 'Dog'), (2, 'Cat'), (2, 'Rabbit'), diff --git a/web/app/about/page.js b/web/app/about/page.js index b15ad5e6..7695e81d 100644 --- a/web/app/about/page.js +++ b/web/app/about/page.js @@ -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 (
diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js index 9a1ae6d3..ad31e4c5 100644 --- a/web/app/adopt/[id]/page.js +++ b/web/app/adopt/[id]/page.js @@ -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); diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 7cc23723..472c9d90 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -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 ( + <> +
+ {apptSlice.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petName && ( +
Pet: {a.petName}
+ )} +
+ +
+ ))} +
+ {apptTotalPages > 1 && ( +
+ + Page {apptPage + 1} of {apptTotalPages} +
- ))} - + )} + )} {pastAppts.length > 0 && (
- {showPastAppts && ( -
- {pastAppts.map((a) => ( -
-
- {a.serviceName} - - {a.appointmentStatus} - + <> +
+ {pastApptSlice.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petName && ( +
Pet: {a.petName}
+ )}
-
- {a.storeName} - {a.appointmentDate} at {formatTime(a.appointmentTime)} -
- {a.petName && ( -
Pet: {a.petName}
- )} + ))} +
+ {pastApptTotalPages > 1 && ( +
+ + Page {pastApptPage + 1} of {pastApptTotalPages} +
- ))} -
+ )} + )}
)} @@ -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 ( <> {activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}

) : ( -
- {filteredActive.map((a) => ( -
-
- {a.petName} - - {a.adoptionStatus} - -
-
- {a.sourceStoreName} - {a.adoptionDate} -
-
- + <> +
+ {adoptionSlice.map((a) => ( +
+
+ {a.petName} + + {a.adoptionStatus} + +
+
+ {a.sourceStoreName} + {a.adoptionDate} +
+
+ +
+ ))} +
+ {adoptionTotalPages > 1 && ( +
+ + Page {adoptionPage + 1} of {adoptionTotalPages} +
- ))} -
+ )} + )} {pastAdoptions.length > 0 && (
- {showPastAdoptions && ( -
- {pastAdoptions.map((a) => ( -
-
- {a.petName} - - {a.adoptionStatus} - -
-
- {a.sourceStoreName} - {a.adoptionDate} + <> +
+ {pastAdoptionSlice.map((a) => ( +
+
+ {a.petName} + + {a.adoptionStatus} + +
+
+ {a.sourceStoreName} + {a.adoptionDate} +
+ ))} +
+ {pastAdoptionTotalPages > 1 && ( +
+ + Page {pastAdoptionPage + 1} of {pastAdoptionTotalPages} +
- ))} -
+ )} + )}
)} diff --git a/web/app/cart/page.js b/web/app/cart/page.js index a7bd0d2a..b3e102b3 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -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 }) {

Total to pay: ${parseFloat(totalAmount).toFixed(2)}

-
-
Demo mode — no real charge. Use test card: 4242 4242 4242 4242 · any future date · any 3-digit CVC
-
{payError &&

{payError}

}
@@ -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 && ( - + { 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 { diff --git a/web/app/contact/page.js b/web/app/contact/page.js index 94fd00d1..da7e6bd2 100644 --- a/web/app/contact/page.js +++ b/web/app/contact/page.js @@ -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) { diff --git a/web/app/forgot-password/page.js b/web/app/forgot-password/page.js index 753e8d07..4664a2cb 100644 --- a/web/app/forgot-password/page.js +++ b/web/app/forgot-password/page.js @@ -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(""); diff --git a/web/app/layout.js b/web/app/layout.js index d1466ee6..4e867680 100644 --- a/web/app/layout.js +++ b/web/app/layout.js @@ -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 ( diff --git a/web/app/login/page.js b/web/app/login/page.js index 19344b19..4bdeba2a 100644 --- a/web/app/login/page.js +++ b/web/app/login/page.js @@ -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(""); diff --git a/web/app/page.js b/web/app/page.js index a9394ecf..00d80362 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -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); diff --git a/web/app/products/[id]/page.js b/web/app/products/[id]/page.js index 6add2d5f..5808c51e 100644 --- a/web/app/products/[id]/page.js +++ b/web/app/products/[id]/page.js @@ -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); diff --git a/web/app/products/page.js b/web/app/products/page.js index 155ba778..d1222a18 100644 --- a/web/app/products/page.js +++ b/web/app/products/page.js @@ -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); diff --git a/web/app/profile/page.js b/web/app/profile/page.js index 39cc17dc..591d5c6e 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -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); diff --git a/web/app/register/page.js b/web/app/register/page.js index c3ea2ef0..1948ea57 100644 --- a/web/app/register/page.js +++ b/web/app/register/page.js @@ -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(""); diff --git a/web/app/reset-password/page.js b/web/app/reset-password/page.js index a9e3478c..6cfaeb7a 100644 --- a/web/app/reset-password/page.js +++ b/web/app/reset-password/page.js @@ -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(""); diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js index a5c6f9f3..73c7bb66 100644 --- a/web/components/ClientProviders.js +++ b/web/components/ClientProviders.js @@ -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 ( diff --git a/web/components/FloatingChat.js b/web/components/FloatingChat.js index 741db28a..505c46ed 100644 --- a/web/components/FloatingChat.js +++ b/web/components/FloatingChat.js @@ -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, diff --git a/web/components/Footer.js b/web/components/Footer.js index c208793d..20b1f640 100644 --- a/web/components/Footer.js +++ b/web/components/Footer.js @@ -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 (