merge main into websitefinal

This commit is contained in:
2026-04-15 12:26:31 -06:00
212 changed files with 10517 additions and 1699 deletions

View File

@@ -1 +1,4 @@
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Backend URL for the API proxy — swap comments to switch between local and remote
BACKEND_URL=http://localhost:8080
#BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io

17
web/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -74,13 +74,20 @@ export default function AdoptPage() {
[pets]
);
const displayedPets = useMemo(
const ITEMS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(0);
const filteredPets = useMemo(
() => (selectedBreed ? pets.filter((p) => p.petBreed === selectedBreed) : pets),
[pets, selectedBreed]
);
const totalPages = Math.ceil(filteredPets.length / ITEMS_PER_PAGE);
const displayedPets = filteredPets.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
function handleSearch(e) {
e.preventDefault();
setCurrentPage(0);
setQuery(search.trim());
}
@@ -89,6 +96,7 @@ export default function AdoptPage() {
setQuery("");
setSelectedSpecies("");
setSelectedBreed("");
setCurrentPage(0);
}
const hasActiveFilters = query || selectedSpecies || selectedBreed;
@@ -109,7 +117,7 @@ export default function AdoptPage() {
id="species-filter"
className="adopt-filter-select"
value={selectedSpecies}
onChange={(e) => setSelectedSpecies(e.target.value)}
onChange={(e) => { setSelectedSpecies(e.target.value); setCurrentPage(0); }}
>
<option value="">All Species</option>
{speciesOptions.map((s) => (
@@ -124,7 +132,7 @@ export default function AdoptPage() {
id="breed-filter"
className="adopt-filter-select"
value={selectedBreed}
onChange={(e) => setSelectedBreed(e.target.value)}
onChange={(e) => { setSelectedBreed(e.target.value); setCurrentPage(0); }}
disabled={!selectedSpecies}
>
<option value="">{!selectedSpecies ? "Select a species first" : "All Breeds"}</option>
@@ -159,11 +167,7 @@ export default function AdoptPage() {
{loading && <p className="adopt-status-msg">Loading pets...</p>}
{error && (
<div className="adopt-error-box">
<p className="adopt-error-title">Failed to load pets</p>
<code className="adopt-error-detail">{error}</code>
<p className="adopt-error-hint">Make sure the backend is running and try again.</p>
</div>
<p className="adopt-status-msg">Unable to load pets, please try again later.</p>
)}
{!loading && !error && displayedPets.length === 0 && (
@@ -184,6 +188,25 @@ export default function AdoptPage() {
))}
</div>
)}
{!loading && !error && totalPages > 1 && (
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
Prev
</button>
<span className="pagination-info">Page {currentPage + 1} of {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
</button>
</div>
)}
</section>
</main>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
const BACKEND = process.env.BACKEND_URL || 'http://localhost:8080'
async function proxy(request, { params }) {
const path = (await params).path.join('/')
const { search } = new URL(request.url)
const url = `${BACKEND}/api/${path}${search}`
const headers = new Headers(request.headers)
headers.delete('host')
headers.delete('origin')
const init = { method: request.method, headers }
if (!['GET', 'HEAD'].includes(request.method)) {
init.body = request.body
init.duplex = 'half'
}
const res = await fetch(url, init)
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
})
}
export { proxy as GET, proxy as POST, proxy as PUT, proxy as DELETE, proxy as PATCH }

View File

@@ -2,6 +2,7 @@
import dynamic from "next/dynamic";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
@@ -339,6 +340,8 @@ function AppointmentsPage() {
const adoptionStoreName = searchParams.get("storeName") || "";
const didPreselectRef = useRef(false);
const errorRef = useRef(null);
const historyRef = useRef(null);
// Adoption-mode URL verification
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
@@ -364,6 +367,12 @@ function AppointmentsPage() {
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
useEffect(() => {
if (error && errorRef.current) {
errorRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [error]);
const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false);
@@ -425,12 +434,12 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
})
.then((r) => r.json())
.then(setStores)
.catch(() => {});
.catch(() => setError("Failed to load stores."));
fetch(`${API_BASE}/api/v1/services?size=100`)
.then((r) => r.json())
.then((data) => setServices(data.content ?? []))
.catch(() => {});
.catch(() => setError("Failed to load services."));
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`)
.then((r) => r.json())
@@ -499,9 +508,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [token]);
useEffect(() => {
if (adoptionMode) loadAdoptions();
else loadAppointments();
}, [adoptionMode, loadAppointments, loadAdoptions]);
loadAppointments();
loadAdoptions();
}, [loadAppointments, loadAdoptions]);
async function handleCancelAppointment(appointmentId) {
if (!confirm("Cancel this appointment?")) return;
@@ -696,6 +705,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
setEmployeeId("");
loadAdoptions();
setTimeout(() => historyRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 300);
return;
}
@@ -736,6 +746,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
setSelectedPetIds([]);
setAvailableSlots([]);
loadAppointments();
setTimeout(() => historyRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 300);
}
catch (err) {
@@ -779,7 +790,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<form className="appt-form" onSubmit={handleSubmit}>
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
{error && <div className="appt-error">{error}</div>}
{error && <div className="appt-error" ref={errorRef}>{error}</div>}
{adoptionMode && adoptionVerifyLoading && (
<p className="appt-loading">Verifying pet details</p>
@@ -805,7 +816,10 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<div className="appt-label">
<span>Select Your Pet</span>
{eligiblePets.length === 0 ? (
<p className="appt-no-slots">You have no adopted pets available for appointments.</p>
<p className="appt-no-slots">
You have no adopted pets available.{" "}
<Link href="/profile">Add a pet on your profile page.</Link>
</p>
) : (
<div className="appt-pets-grid">
{eligiblePets.map((p) => (
@@ -930,6 +944,8 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</div>
)}
</>)}
<button
type="submit"
className="appt-submit-btn"
@@ -941,51 +957,12 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
{success && <div className="appt-success">{success}</div>}
</>)}
</>)}
</form>
) : null}
<div className="appt-history">
<h2 className="appt-form-title">
{adoptionMode ? "Your Adoptions" : canBookAppointments ? "Your Appointments" : "Appointments"}
</h2>
{adoptionMode ? (
loadingAdoptions ? (
<p className="appt-loading">Loading adoptions...</p>
) : adoptions.length === 0 ? (
<p className="appt-empty">No adoption appointments yet.</p>
) : (
<div className="appt-list">
{adoptions.map((a) => (
<div key={a.adoptionId} className="appt-card">
<div className="appt-card-header">
<span className="appt-card-service">{a.petName}</span>
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
{a.adoptionStatus?.toLowerCase() === "pending" && (
<div className="appt-card-actions">
<button
type="button"
className="appt-cancel-btn"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
)}
</div>
))}
</div>
)
) : loadingAppointments ? (
<div className="appt-history" ref={historyRef}>
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
{loadingAppointments ? (
<p className="appt-loading">Loading appointments...</p>
) : appointments.length === 0 ? (
<p className="appt-empty">No appointments yet.</p>
@@ -995,7 +972,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<div key={a.appointmentId} className="appt-card">
<div className="appt-card-header">
<span className="appt-card-service">{a.serviceName}</span>
<span className={`appt-card-status appt-card-status--${a.appointmentStatus.toLowerCase()}`}>
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
@@ -1003,14 +980,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petNames && a.petNames.length > 0 && (
{a.petName && (
<div className="appt-card-pets">
Pets: {a.petNames.join(", ")}
</div>
)}
{a.customerPetNames && a.customerPetNames.length > 0 && (
<div className="appt-card-pets">
Pets: {a.customerPetNames.join(", ")}
Pet: {a.petName}
</div>
)}
{a.appointmentStatus?.toLowerCase() === "booked" && (
@@ -1029,6 +1001,42 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
))}
</div>
)}
<h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
{loadingAdoptions ? (
<p className="appt-loading">Loading adoptions...</p>
) : adoptions.length === 0 ? (
<p className="appt-empty">No adoption requests yet.</p>
) : (
<div className="appt-list">
{adoptions.map((a) => (
<div key={a.adoptionId} className="appt-card">
<div className="appt-card-header">
<span className="appt-card-service">{a.petName}</span>
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
{a.adoptionStatus?.toLowerCase() === "pending" && (
<div className="appt-card-actions">
<button
type="button"
className="appt-cancel-btn"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</section>
</main>

View File

@@ -43,6 +43,9 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
try {
await apiCompleteCheckout(token, paymentIntentId);
} catch {
setPayError("Order confirmation failed. Please contact support.");
setPaying(false);
return;
}
onSuccess();
}
@@ -74,7 +77,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
}
export default function CartPage() {
const { user, loading: authLoading } = useAuth();
const { user, loading: authLoading, refreshUser } = useAuth();
const {
cart,
cartLoading,
@@ -84,6 +87,8 @@ export default function CartPage() {
removeItem,
clearCart,
applyCoupon,
applyPoints,
removeCoupon,
checkout,
cancelCheckout,
} = useCart();
@@ -91,8 +96,13 @@ export default function CartPage() {
const [couponInput, setCouponInput] = useState("");
const [couponError, setCouponError] = useState(null);
const [couponSuccess, setCouponSuccess] = useState(null);
const [couponLoading, setCouponLoading] = useState(false);
const [pointsLoading, setPointsLoading] = useState(false);
const [pointsError, setPointsError] = useState(null);
const [optimisticPointsApplied, setOptimisticPointsApplied] = useState(null);
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [checkoutError, setCheckoutError] = useState(null);
const [clientSecret, setClientSecret] = useState(null);
@@ -113,6 +123,8 @@ export default function CartPage() {
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
setLocalQuantities(map);
}
// Sync optimistic state back to server truth whenever cart updates
setOptimisticPointsApplied(null);
}, [cart]);
// If the cart arrives already locked (e.g. user closed the page mid-checkout)
@@ -156,15 +168,55 @@ export default function CartPage() {
if (!couponInput.trim()) return;
setCouponLoading(true);
setCouponError(null);
setCouponSuccess(null);
try {
await applyCoupon(couponInput.trim());
const updated = await applyCoupon(couponInput.trim());
setCouponInput("");
}
const dtype = updated.couponDiscountType?.toUpperCase();
const discountLabel =
(dtype === "PERCENTAGE" || dtype === "PERCENT") && updated.couponDiscountValue != null
? `${parseFloat(updated.couponDiscountValue)}% off`
: (dtype === "FIXED" || dtype === "FLAT") && updated.couponDiscountValue != null
? `$${parseFloat(updated.couponDiscountValue).toFixed(2)} off`
: "";
setCouponSuccess(`Coupon "${updated.couponCode}" applied${discountLabel ? ` (${discountLabel})` : ""}!`);
}
catch (err) {
setCouponError(err.message);
}
}
finally {
setCouponLoading(false);
}
}
async function handleTogglePoints(checked) {
setOptimisticPointsApplied(checked);
setPointsLoading(true);
setPointsError(null);
try {
await applyPoints(checked);
} catch (err) {
setOptimisticPointsApplied(null);
setPointsError(err.message || "Failed to apply loyalty points.");
} finally {
setPointsLoading(false);
}
}
async function handleRemoveCoupon() {
setCouponLoading(true);
setCouponError(null);
setCouponSuccess(null);
try {
await removeCoupon();
}
catch (err) {
setCouponError(err.message);
}
finally {
setCouponLoading(false);
}
@@ -182,6 +234,7 @@ export default function CartPage() {
}
else if (result?.status === "succeeded") {
refreshUser().catch(() => {});
setConfirmed(true);
}
}
@@ -309,32 +362,131 @@ export default function CartPage() {
</div>
{parseFloat(cart.discountAmount ?? 0) > 0 && (
<div className="cart-summary-row cart-summary-discount">
<span>Discount {cart.couponCode && `(${cart.couponCode})`}</span>
<span>
Coupon discount
{cart.couponCode && ` (${cart.couponCode}`}
{(() => {
const t = cart.couponDiscountType?.toUpperCase();
if ((t === "PERCENTAGE" || t === "PERCENT") && cart.couponDiscountValue != null)
return ` - ${parseFloat(cart.couponDiscountValue)}% off`;
if ((t === "FIXED" || t === "FLAT") && cart.couponDiscountValue != null)
return ` - $${parseFloat(cart.couponDiscountValue).toFixed(2)} off`;
return "";
})()}
{cart.couponCode && ")"}
</span>
<span>${parseFloat(cart.discountAmount).toFixed(2)}</span>
</div>
)}
{parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && (
<div className="cart-summary-row cart-summary-discount">
<span>Loyalty discount ({Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts)</span>
<span>${parseFloat(cart.pointsDiscountAmount).toFixed(2)}</span>
</div>
)}
<div className="cart-summary-row cart-summary-total">
<span>Total</span>
<span>${parseFloat(cart.totalAmount ?? 0).toFixed(2)}</span>
<div className="cart-total-prices">
{(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) && (
<span className="cart-total-original">
${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}
</span>
)}
<span className={(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "cart-total-discounted" : ""}>
${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
</span>
</div>
</div>
{parseFloat(cart.discountAmount ?? 0) > 0 && (
<div className="cart-savings-callout">
You save ${parseFloat(cart.discountAmount).toFixed(2)}!
</div>
)}
{user?.role === "CUSTOMER" && (
<div className="cart-points-estimate">
Earn <strong>{Math.floor(parseFloat(cart.totalAmount ?? 0))}</strong> loyalty point{Math.floor(parseFloat(cart.totalAmount ?? 0)) !== 1 ? "s" : ""} with this purchase
</div>
)}
{user?.role === "CUSTOMER" && (
<div className="cart-points-section">
<div className="cart-points-balance-row">
<span>Your points balance:</span>
<strong>{cart.availableLoyaltyPoints ?? 0} pts</strong>
</div>
{(cart.availableLoyaltyPoints ?? 0) < 20 ? (
<p className="cart-points-msg">You need at least 20 points to redeem $1.</p>
) : (
<label className="cart-points-label">
<input
type="checkbox"
className="cart-points-checkbox"
checked={optimisticPointsApplied !== null ? optimisticPointsApplied : !!cart.pointsApplied}
disabled={pointsLoading}
onChange={(e) => handleTogglePoints(e.target.checked)}
/>
Use loyalty points for this purchase
</label>
)}
{pointsError && <p className="cart-points-msg" style={{ color: "#dc2626" }}>{pointsError}</p>}
{(optimisticPointsApplied ?? !!cart.pointsApplied) && parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && (
<div className="cart-points-applied-detail">
<span>Applying {Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts:</span>
<span>${parseFloat(cart.pointsDiscountAmount).toFixed(2)} off</span>
</div>
)}
</div>
)}
<div className="cart-coupon-section">
<input
className="cart-coupon-input"
type="text"
placeholder="Coupon code"
value={couponInput}
onChange={(e) => setCouponInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleApplyCoupon()}
/>
<button
className="cart-coupon-btn"
type="button"
onClick={handleApplyCoupon}
disabled={couponLoading}
>
{couponLoading ? "…" : "Apply"}
</button>
{cart.couponCode && (
<div className="cart-coupon-applied">
<span className="cart-coupon-badge">
{cart.couponCode}
{(() => {
const t = cart.couponDiscountType?.toUpperCase();
if ((t === "PERCENTAGE" || t === "PERCENT") && cart.couponDiscountValue != null)
return ` - ${parseFloat(cart.couponDiscountValue)}% off`;
if ((t === "FIXED" || t === "FLAT") && cart.couponDiscountValue != null)
return ` - $${parseFloat(cart.couponDiscountValue).toFixed(2)} off`;
return "";
})()}
</span>
<button
className="cart-coupon-remove-btn"
type="button"
onClick={handleRemoveCoupon}
disabled={couponLoading}
title="Remove coupon"
>
</button>
</div>
)}
<div className="cart-coupon-input-row">
<input
className="cart-coupon-input"
type="text"
placeholder={cart.couponCode ? "Enter new code to replace" : "Coupon code"}
value={couponInput}
onChange={(e) => { setCouponInput(e.target.value); setCouponError(null); setCouponSuccess(null); }}
onKeyDown={(e) => e.key === "Enter" && handleApplyCoupon()}
/>
<button
className="cart-coupon-btn"
type="button"
onClick={handleApplyCoupon}
disabled={couponLoading || !couponInput.trim()}
>
{couponLoading ? "…" : "Apply"}
</button>
</div>
{cart.couponCode && (
<p className="cart-coupon-hint">Applying a new code will replace the current coupon.</p>
)}
{couponSuccess && <p className="cart-coupon-success">{couponSuccess}</p>}
{couponError && <p className="cart-coupon-error">{couponError}</p>}
</div>
@@ -359,6 +511,7 @@ export default function CartPage() {
onSuccess={() => {
setClientSecret(null);
setConfirmed(true);
refreshUser().catch(() => {});
}}
onCancel={async () => {
await cancelCheckout().catch(() => {});

View File

@@ -20,11 +20,17 @@ function ChatPage() {
const [sending, setSending] = useState(false);
const [loadingConv, setLoadingConv] = useState(true);
const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null);
const inputRef = useRef(null);
const pollRef = useRef(null);
const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null);
const lastScrolledIdRef = useRef(null);
useEffect(() => {
if (!authLoading && !user) {
@@ -32,16 +38,19 @@ function ChatPage() {
}
}, [authLoading, user, router, conversationIdParam]);
const prevMsgLengthRef = useRef(0);
useEffect(() => {
if (messages.length > prevMsgLengthRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
prevMsgLengthRef.current = messages.length;
useEffect(() => {
if (messages.length === 0) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg.id === lastScrolledIdRef.current) return;
lastScrolledIdRef.current = lastMsg.id;
const area = messagesAreaRef.current;
if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
if (nearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return;
try {
@@ -63,7 +72,7 @@ useEffect(() => {
}
catch {
//Silent fail
setError("Failed to load messages.");
}
}, [token]);
@@ -88,6 +97,23 @@ useEffect(() => {
}
}, [token]);
const fetchConversations = useCallback(async () => {
if (!token) return;
setConvsLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
setConversations(Array.isArray(data) ? data : (data.content ?? []));
} catch {
// silent
} finally {
setConvsLoading(false);
}
}, [token]);
const startPolling = useCallback((convId) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
@@ -145,18 +171,22 @@ useEffect(() => {
if (open) convId = open.id;
}
} catch {
//
setError("Failed to load conversations.");
}
}
if (!convId) {
await fetchConversations();
setLoadingConv(false);
setConversation(null);
return;
}
await fetchConversation(convId);
await fetchMessages(convId);
await Promise.all([
fetchConversation(convId),
fetchMessages(convId),
fetchConversations(),
]);
setLoadingConv(false);
startPolling(convId);
}
@@ -166,13 +196,20 @@ useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling]);
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]);
async function handleSend(e) {
e?.preventDefault();
const text = input.trim();
if (!text || sending || !conversation) return;
if ((!text && !selectedFile) || sending || !conversation) return;
if (selectedFile) {
await handleSendAttachment(text);
} else {
await handleSendText(text);
}
}
async function handleSendText(text) {
setInput("");
setSending(true);
setError(null);
@@ -214,6 +251,59 @@ useEffect(() => {
}
}
async function handleSendAttachment(optionalText) {
setSending(true);
setError(null);
const file = selectedFile;
setSelectedFile(null);
setInput("");
try {
const formData = new FormData();
formData.append("file", file);
if (optionalText) formData.append("content", optionalText);
const res = await fetch(
`${API_BASE}/api/v1/chat/conversations/${conversation.id}/attachments`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
}
);
if (res.status === 401) {
router.push("/login?next=" + encodeURIComponent("/chat"));
return;
}
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.message || "Failed to send attachment.");
setSelectedFile(file);
setInput(optionalText);
return;
}
const msg = await res.json();
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id;
} catch {
setError("Network error. Please try again.");
setSelectedFile(file);
setInput(optionalText);
} finally {
setSending(false);
inputRef.current?.focus();
}
}
function handleFileChange(e) {
const file = e.target.files?.[0];
if (file) setSelectedFile(file);
e.target.value = "";
}
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -248,7 +338,7 @@ useEffect(() => {
});
setConversation(conv);
await fetchMessages(conv.id);
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
setLoadingConv(false);
startPolling(conv.id);
router.replace(`/chat?id=${conv.id}`, { scroll: false });
@@ -258,6 +348,38 @@ useEffect(() => {
}
}
async function switchConversation(convId) {
if (pollRef.current) clearInterval(pollRef.current);
setMessages([]);
setError(null);
setLoadingConv(true);
await fetchConversation(convId);
await fetchMessages(convId);
setLoadingConv(false);
startPolling(convId);
router.replace(`/chat?id=${convId}`, { scroll: false });
}
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: "CLOSED" }),
});
if (!res.ok) return;
const updated = await res.json();
setConversation(updated);
await fetchConversations();
} catch {
// silent
}
}
if (authLoading || loadingConv) {
return (
<main style={s.page}>
@@ -271,6 +393,7 @@ useEffect(() => {
const isHuman = conversation?.mode === "HUMAN";
const hasStaff = !!conversation?.staffId;
const isClosed = conversation?.status === "CLOSED";
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
const staffStatusLabel = isClosed
? "Conversation closed"
@@ -291,6 +414,40 @@ useEffect(() => {
</section>
<section style={s.chatSection}>
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-start" }}>
<div style={s.sidebar}>
<div style={s.sidebarHeader}>
<span style={s.sidebarTitle}>All Conversations</span>
</div>
{convsLoading && <p style={s.sidebarEmpty}>Loading...</p>}
{!convsLoading && conversations.length === 0 && (
<p style={s.sidebarEmpty}>No conversations yet.</p>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
{conv.status}
</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</div>
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
+ New Conversation
</button>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{!conversation ? (
<div style={s.noConvCard}>
<div style={s.noConvIcon}>💬</div>
@@ -319,23 +476,34 @@ useEffect(() => {
</div>
</div>
</div>
<button
style={s.aiBtn}
onClick={() => router.push("/ai-chat")}
title="Back to AI Assistant"
>
AI Assistant
</button>
<div style={{ display: "flex", gap: "0.5rem" }}>
{!isClosed && (
<button
style={s.closeConvBtn}
onClick={handleCloseConversation}
title="Close this conversation"
>
Close Chat
</button>
)}
<button
style={s.aiBtn}
onClick={() => router.push("/ai-chat")}
title="Back to AI Assistant"
>
AI Assistant
</button>
</div>
</div>
{!hasStaff && !isClosed && (
{!hasStaff && !hasStaffMessage && !isClosed && (
<div style={s.waitingBanner}>
<span style={s.waitingSpinner} />
A support agent will be with you shortly. You can send messages while you wait.
</div>
)}
<div style={s.messagesArea}>
<div style={s.messagesArea} ref={messagesAreaRef}>
{messages.length === 0 && (
<div style={s.emptyState}>
<p style={s.emptyText}>
@@ -412,31 +580,62 @@ useEffect(() => {
</div>
) : (
<form style={s.inputArea} onSubmit={handleSend}>
<textarea
ref={inputRef}
style={s.textarea}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
disabled={sending}
maxLength={2000}
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<button
type="submit"
style={{
...s.sendBtn,
...((!input.trim() || sending) ? s.sendBtnDisabled : {}),
}}
disabled={!input.trim() || sending}
>
{sending ? "..." : "Send"}
</button>
{selectedFile && (
<div style={s.filePreview}>
<span style={s.filePreviewName}>📎 {selectedFile.name}</span>
<button
type="button"
style={s.filePreviewRemove}
onClick={() => setSelectedFile(null)}
>
</button>
</div>
)}
<div style={s.inputRow}>
<button
type="button"
style={s.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach a file"
>
📎
</button>
<textarea
ref={inputRef}
style={s.textarea}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={selectedFile ? "Add a caption (optional)..." : "Type a message..."}
rows={1}
disabled={sending}
maxLength={2000}
/>
<button
type="submit"
style={{
...s.sendBtn,
...((!input.trim() && !selectedFile) || sending ? s.sendBtnDisabled : {}),
}}
disabled={(!input.trim() && !selectedFile) || sending}
>
{sending ? "..." : "Send"}
</button>
</div>
</form>
)}
</div>
)}
</div>
</div>
</section>
</main>
);
@@ -479,7 +678,7 @@ const s = {
margin: "1rem auto 0",
},
chatSection: {
maxWidth: 800,
maxWidth: 1060,
margin: "0 auto",
padding: "1.5rem 1rem 2rem",
},
@@ -736,11 +935,16 @@ const s = {
},
inputArea: {
display: "flex",
gap: "0.6rem",
flexDirection: "column",
gap: "0.4rem",
padding: "0.85rem 1.25rem",
borderTop: "1px solid #f0f0f0",
background: "#fff",
flexShrink: 0,
},
inputRow: {
display: "flex",
gap: "0.6rem",
alignItems: "flex-end",
},
textarea: {
@@ -771,6 +975,143 @@ const s = {
background: "#aaa",
cursor: "not-allowed",
},
attachBtn: {
background: "none",
border: "1.5px solid #e0e0e0",
borderRadius: 8,
padding: "0.5rem 0.6rem",
fontSize: "1rem",
cursor: "pointer",
flexShrink: 0,
color: "#666",
lineHeight: 1,
},
filePreview: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
background: "#f8f8f8",
border: "1px solid #e8e8e8",
borderRadius: 8,
padding: "0.35rem 0.75rem",
},
filePreviewName: {
fontSize: "0.82rem",
color: "#555",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
filePreviewRemove: {
background: "none",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8rem",
padding: "0 0.15rem",
flexShrink: 0,
},
closeConvBtn: {
background: "white",
border: "2px solid #c0392b",
color: "#c0392b",
borderRadius: 8,
padding: "0.45rem 0.9rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
sidebar: {
width: 230,
flexShrink: 0,
background: "white",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
maxHeight: "calc(100vh - 220px)",
minHeight: 300,
},
sidebarHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.85rem 1rem",
borderBottom: "1px solid #f0f0f0",
flexShrink: 0,
},
sidebarTitle: { fontWeight: 700, fontSize: "0.88rem", color: "#333" },
sidebarEmpty: {
color: "#aaa",
fontSize: "0.82rem",
padding: "1rem",
textAlign: "center",
margin: 0,
},
convItem: {
display: "flex",
flexDirection: "column",
gap: "0.2rem",
padding: "0.65rem 1rem",
borderBottom: "1px solid #f0f0f0",
background: "white",
border: "none",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: "#f0f0f0",
textAlign: "left",
cursor: "pointer",
width: "100%",
},
convItemActive: { background: "#f8f8f8" },
convItemTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.4rem",
},
convItemBottom: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
convItemSubject: {
fontSize: "0.82rem",
fontWeight: 600,
color: "#222",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
},
convItemMode: { fontSize: "0.7rem", color: "#999" },
convItemDate: { fontSize: "0.68rem", color: "#bbb" },
convStatusBadge: {
fontSize: "0.62rem",
fontWeight: 700,
borderRadius: 20,
padding: "0.1rem 0.45rem",
flexShrink: 0,
textTransform: "uppercase",
letterSpacing: "0.04em",
},
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" },
newConvSidebarBtn: {
margin: "0.65rem 1rem",
background: "#333",
color: "white",
border: "none",
borderRadius: 8,
padding: "0.5rem 0.75rem",
fontSize: "0.8rem",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
},
};
export default dynamic(() => Promise.resolve(ChatPage), { ssr: false });

View File

@@ -1,24 +1,62 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/context/AuthContext";
function getStoreImage(store) {
if (store.imageUrl) return store.imageUrl;
const name = store.storeName?.toLowerCase() ?? "";
if (name.includes("downtown")) return "/stores/downtown.webp";
if (name.includes("north")) return "/stores/north.webp";
if (name.includes("west")) return "/stores/west.webp";
return "/images/pet-placeholder.png";
}
export default function ContactPage() {
const { token } = useAuth();
const [locations, setLocations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState(null);
const [sendSuccess, setSendSuccess] = useState(false);
useEffect(() => {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) throw new Error("Unable to load store, please try again later.");
return res.json();
})
.then((data) => setLocations(data.content ?? []))
.catch((err) => setError(err.message))
.catch(() => setError("Unable to load store, please try again later."))
.finally(() => setLoading(false));
}, []);
async function handleSend(e) {
e.preventDefault();
setSending(true);
setSendError(null);
try {
const res = await fetch("/api/v1/contact", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ subject, body }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setSendSuccess(true);
setSubject("");
setBody("");
} catch (err) {
setSendError("Failed to send message. Please try again.");
} finally {
setSending(false);
}
}
return (
<main className="info-page">
<section className="info-hero">
@@ -30,11 +68,50 @@ export default function ContactPage() {
<section className="info-content">
<div className="info-card">
<h2>General Contact</h2>
<p>Email: support@petshop.com</p>
<p>Phone: (000) 000-0000</p>
<p>Email: hello@leonspetstore.com.au</p>
<p>Phone: (03) 9000 0000</p>
<p>Hours: MonSat, 9:00 AM 6:00 PM</p>
</div>
{token && (
<div className="info-card">
<h2>Send Us a Message</h2>
{sendSuccess ? (
<p className="contact-success">Your message has been sent. We&apos;ll be in touch soon.</p>
) : (
<form className="auth-form" onSubmit={handleSend}>
<label className="auth-label">
Subject
<input
className="auth-input"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
maxLength={150}
/>
</label>
<label className="auth-label">
Message
<textarea
className="auth-input"
style={{ resize: "vertical" }}
value={body}
onChange={(e) => setBody(e.target.value)}
required
maxLength={2000}
rows={6}
/>
</label>
{sendError && <p className="contact-error">{sendError}</p>}
<button className="auth-submit-btn" type="submit" disabled={sending}>
{sending ? "Sending…" : "Send Message"}
</button>
</form>
)}
</div>
)}
<div className="info-card">
<h2>Store Locations</h2>
@@ -52,7 +129,7 @@ export default function ContactPage() {
<article key={location.storeId} className="info-mini-card location-card">
<div className="location-card-image-wrapper">
<img
src={location.imageUrl || "/images/pet-placeholder.png"}
src={getStoreImage(location)}
alt={location.storeName}
className="location-card-image"
onError={(e) => {

View File

@@ -0,0 +1,92 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useState } from "react";
function ForgotPasswordPage() {
const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
try {
const res = await fetch("/api/v1/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ usernameOrEmail }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Something went wrong. Please try again.");
}
setMessage(data.message || "If an account matches, a reset link has been sent to your email.");
setSubmitted(true);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Forgot Password</h1>
{!submitted ? (
<>
<p style={{ color: "#6b7280", marginBottom: "1.25rem", fontSize: "0.95rem" }}>
Enter your username or email address and we&apos;ll send you a link to reset your password.
</p>
{error && <p className="auth-error">{error}</p>}
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-label">
Username or Email
<input
className="auth-input"
type="text"
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
required
autoComplete="username"
/>
</label>
<button className="auth-submit-btn" type="submit" disabled={loading}>
{loading ? "Sending…" : "Send Reset Link"}
</button>
</form>
</>
) : (
<p style={{ color: "#16a34a", margin: "1rem 0", lineHeight: 1.6 }}>{message}</p>
)}
<p className="auth-switch">
Remember your password?{" "}
<Link href="/login" className="auth-switch-link">Log in here</Link>
</p>
<p className="auth-switch">
Don&apos;t have an account?{" "}
<Link href="/register" className="auth-switch-link">Register here</Link>
</p>
</div>
</main>
);
}
export default dynamic(() => Promise.resolve(ForgotPasswordPage), {
ssr: false,
});

View File

@@ -12,12 +12,6 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
@@ -1452,6 +1446,16 @@ body {
margin: 0.25rem 0 0;
}
.appt-no-slots a {
color: #2563eb;
text-decoration: underline;
font-weight: 500;
}
.appt-no-slots a:hover {
color: #1d4ed8;
}
.appt-pets-grid {
display: flex;
flex-wrap: wrap;
@@ -2386,13 +2390,158 @@ body {
margin-top: 0.25rem;
}
.cart-total-prices {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cart-total-original {
font-size: 0.95rem;
font-weight: 500;
color: #aaa;
text-decoration: line-through;
}
.cart-total-discounted {
color: #2e7d32;
}
.cart-savings-callout {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #15803d;
border-radius: 8px;
padding: 0.5rem 0.85rem;
font-size: 0.88rem;
font-weight: 700;
text-align: center;
}
.cart-points-estimate {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
border-radius: 8px;
padding: 0.5rem 0.85rem;
font-size: 0.85rem;
text-align: center;
}
.cart-points-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.cart-points-balance-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: #374151;
}
.cart-points-balance-row strong {
color: #7c3aed;
font-weight: 700;
}
.cart-points-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #374151;
cursor: pointer;
user-select: none;
}
.cart-points-label:has(.cart-points-checkbox:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
.cart-points-checkbox {
width: 1rem;
height: 1rem;
accent-color: #7c3aed;
cursor: pointer;
flex-shrink: 0;
}
.cart-points-msg {
font-size: 0.82rem;
color: #6b7280;
margin: 0;
font-style: italic;
}
.cart-points-applied-detail {
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 6px;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
color: #5b21b6;
font-weight: 500;
}
.cart-coupon-section {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
}
.cart-coupon-applied {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cart-coupon-badge {
display: inline-flex;
align-items: center;
background: #fff3e0;
color: #a65c00;
border: 1px solid #ffd180;
border-radius: 6px;
padding: 0.3rem 0.7rem;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cart-coupon-remove-btn {
background: none;
border: none;
color: #999;
font-size: 0.85rem;
cursor: pointer;
padding: 0.2rem 0.35rem;
border-radius: 4px;
line-height: 1;
transition: color 0.15s ease, background 0.15s ease;
}
.cart-coupon-remove-btn:hover:not(:disabled) {
color: #c0392b;
background: #fff0f0;
}
.cart-coupon-input-row {
display: flex;
gap: 0.5rem;
}
.cart-coupon-input {
flex: 1;
min-width: 0;
@@ -2418,12 +2567,31 @@ body {
font-weight: 700;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
}
.cart-coupon-btn:hover:not(:disabled) {
background: #111;
}
.cart-coupon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cart-coupon-hint {
font-size: 0.78rem;
color: #888;
margin: 0;
}
.cart-coupon-success {
width: 100%;
font-size: 0.8rem;
color: #16a34a;
margin: 0;
}
.cart-coupon-error {
width: 100%;
font-size: 0.8rem;
@@ -2928,3 +3096,17 @@ html, body {
img, video, iframe {
max-width: 100%;
}
.contact-form { display: flex; flex-direction: column; gap: 1rem; }
.contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; }
.contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; }
.contact-input:focus, .contact-textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
.contact-submit-btn { align-self: flex-start; background: #2563eb; color: #fff; border: none; border-radius: 8px; padding: 0.65rem 1.4rem; font-size: 0.95rem; cursor: pointer; }
.contact-submit-btn:hover:not(:disabled) { background: #1d4ed8; }
.contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.contact-error { color: #c0392b; font-size: 0.9rem; }
.contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; }
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem 1rem; }
.pagination-btn { background: #333; color: white; border: none; border-radius: 8px; padding: 0.5rem 1.2rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
.pagination-btn:disabled { background: #ccc; cursor: not-allowed; }
.pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; }

View File

@@ -82,6 +82,11 @@ function LoginPage() {
Don&apos;t have an account?{" "}
<Link href={searchParams.get("next") ? `/register?next=${encodeURIComponent(searchParams.get("next"))}` : "/register"} className="auth-switch-link">Register here</Link>
</p>
<p className="auth-switch">
Forgot your password?{" "}
<Link href="/forgot-password" className="auth-switch-link">Reset it here</Link>
</p>
</div>
</main>
);

View File

@@ -15,7 +15,6 @@ 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"},
];
export default function Home() {
@@ -77,41 +76,33 @@ export default function Home() {
</div>
</section>
{/* About Us Section */}
{/* About Us */}
<section className="info-page">
<div className="info-hero">
<h2 className="info-title">About Leon&apos;s Pet Store</h2>
<p className="info-subtitle">Pet care, adoption support, grooming, and everyday essentials in one place.</p>
<p className="info-subtitle">Your trusted local destination for pet care, adoption, and supplies built on a love for animals and community.</p>
<div className="title-decoration"></div>
</div>
<div className="info-content">
<div className="info-card">
<h2>What We Do</h2>
<p>
Leon&apos;s Pet Store connects families with adoptable pets, helpful services, and quality products for day-to-day pet care.
</p>
<h3>What We Do</h3>
<p>Leon&apos;s Pet Store is a full-service pet shop offering adoptions, grooming, veterinary appointments, and a wide range of supplies to keep your pets happy and healthy.</p>
</div>
<div className="info-card">
<h2>Our Focus</h2>
<h3>Our Focus</h3>
<ul className="info-list">
<li>Support responsible pet adoption</li>
<li>Provide grooming and care services</li>
<li>Offer reliable pet supplies and essentials</li>
<li>Create a friendly experience for customers and staff</li>
<li>Offer reliable pet supplies</li>
<li>Create a friendly customer experience</li>
</ul>
</div>
<div className="info-card">
<h2>Visit the Store</h2>
<p>
Browse adoptable pets, schedule appointments, shop products, or contact the team for help finding the right fit for a pet and household.
</p>
<h3>Visit the Store</h3>
<p>Come visit us in person or explore our services online. Whether you&apos;re a first-time pet owner or a seasoned animal lover, we&apos;re here to help every step of the way.</p>
</div>
</div>
</section>
</main>
);
}

View File

@@ -14,10 +14,13 @@ export default function ProductsPage() {
const [query, setQuery] = useState("");
const PAGE_SIZE = 100;
const ITEMS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(0);
useEffect(() => {
setLoading(true);
setError(null);
setCurrentPage(0);
fetchAllPages((page) => {
const params = new URLSearchParams({
@@ -37,10 +40,14 @@ export default function ProductsPage() {
.finally(() => setLoading(false));
}, [query]);
const totalPages = Math.ceil(products.length / ITEMS_PER_PAGE);
const displayedProducts = products.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
function handleSearch(e) {
e.preventDefault();
setLoading(true);
setError(null);
setCurrentPage(0);
setQuery(search.trim());
}
@@ -80,10 +87,7 @@ export default function ProductsPage() {
{loading && <p className="adopt-status-msg">Loading products...</p>}
{error && (
<div className="adopt-error-box">
<p className="adopt-error-title">Failed to load products</p>
<code className="adopt-error-detail">{error}</code>
</div>
<p className="adopt-status-msg">Unable to load products, please try again later.</p>
)}
{!loading && !error && products.length === 0 && (
@@ -92,7 +96,7 @@ export default function ProductsPage() {
{!loading && !error && products.length > 0 && (
<div className="adopt-grid">
{products.map((product) => (
{displayedProducts.map((product) => (
<ProductCard
key={product.prodId}
prodId={product.prodId}
@@ -104,7 +108,25 @@ export default function ProductsPage() {
))}
</div>
)}
{!loading && !error && totalPages > 1 && (
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
Prev
</button>
<span className="pagination-info">Page {currentPage + 1} of {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
</button>
</div>
)}
</section>
</main>
);

View File

@@ -396,6 +396,7 @@ export default function ProfilePage() {
{label: "Email", value: user.email},
{label: "Phone", value: user.phone || "N/A"},
...(user.storeName ? [{ label: "Store", value: user.storeName }] : []),
...(user.role === "CUSTOMER" ? [{ label: "Loyalty Points", value: user.loyaltyPoints ?? 0 }] : []),
];
return (

View File

@@ -135,6 +135,8 @@ function RegisterPage() {
value={form.phone}
onChange={handleChange}
required
pattern="[0-9\-\+\(\) ]{7,15}"
title="Enter a valid phone number"
/>
</label>
@@ -161,6 +163,7 @@ function RegisterPage() {
value={form.confirmPassword}
onChange={handleChange}
required
minLength={6}
autoComplete="new-password"
/>
</label>
@@ -174,6 +177,11 @@ function RegisterPage() {
Already have an account?{" "}
<Link href={searchParams.get("next") ? `/login?next=${encodeURIComponent(searchParams.get("next"))}` : "/login"} className="auth-switch-link">Log in here</Link>
</p>
<p className="auth-switch">
Forgot your password?{" "}
<Link href="/forgot-password" className="auth-switch-link">Reset it here</Link>
</p>
</div>
</main>
);

View File

@@ -0,0 +1,132 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") || "";
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
if (!token) {
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Invalid Link</h1>
<p className="auth-error">
This password reset link is missing or invalid. Please request a new one.
</p>
<p className="auth-switch">
<Link href="/forgot-password" className="auth-switch-link">Request a new reset link</Link>
</p>
</div>
</main>
);
}
async function handleSubmit(e) {
e.preventDefault();
setError("");
if (newPassword !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/v1/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Failed to reset password. The link may have expired.");
}
setSuccess(true);
setTimeout(() => router.push("/login"), 3000);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
if (success) {
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Password Reset</h1>
<p style={{ color: "#16a34a", margin: "1rem 0", lineHeight: 1.6 }}>
Your password has been reset successfully. Redirecting you to login
</p>
<p className="auth-switch">
<Link href="/login" className="auth-switch-link">Go to login</Link>
</p>
</div>
</main>
);
}
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Reset Password</h1>
{error && <p className="auth-error">{error}</p>}
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-label">
New Password
<input
className="auth-input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
</label>
<label className="auth-label">
Confirm New Password
<input
className="auth-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</label>
<button className="auth-submit-btn" type="submit" disabled={loading}>
{loading ? "Resetting…" : "Reset Password"}
</button>
</form>
<p className="auth-switch">
Remember your password?{" "}
<Link href="/login" className="auth-switch-link">Log in here</Link>
</p>
</div>
</main>
);
}
export default dynamic(() => Promise.resolve(ResetPasswordPage), {
ssr: false,
});

View File

@@ -15,14 +15,11 @@ export default function DisplayNav() {
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!token) return;
fetch("/api/v1/stores?size=100", {
headers: { Authorization: `Bearer ${token}` },
})
fetch("/api/v1/stores?size=100")
.then((r) => (r.ok ? r.json() : null))
.then((data) => { if (data) setStores(data.content ?? []); })
.catch(() => {});
}, [token]);
}, []);
function handleLogout() {
logout();

View File

@@ -64,10 +64,15 @@ export function AuthProvider({ children }) {
body: JSON.stringify({ username, password }),
});
const data = await res.json();
let data;
try {
data = await res.json();
} catch {
throw new Error("Unable to log in, please try again later.");
}
if (!res.ok) {
throw new Error(data.message || "Login failed");
throw new Error(data.message || "Unable to log in, please try again later.");
}
const jwt = data.token;
@@ -85,16 +90,22 @@ export function AuthProvider({ children }) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, email, firstName, lastName, phone }),
});
const data = await res.json();
let data;
try {
data = await res.json();
} catch {
throw new Error("Unable to register, please try again later.");
}
if (!res.ok) {
if (data.errors && typeof data.errors === "object") {
const fieldErrors = Object.entries(data.errors)
.map(([field, msg]) => `${field}: ${msg}`)
.join(", ");
throw new Error(fieldErrors || data.message || "Registration failed");
throw new Error(fieldErrors || data.message || "Unable to register, please try again later.");
}
throw new Error(data.message || "Registration failed");
throw new Error(data.message || "Unable to register, please try again later.");
}
const jwt = data.token;

View File

@@ -9,6 +9,8 @@ import {
apiRemoveCartItem,
apiClearCart,
apiApplyCoupon,
apiRemoveCoupon,
apiApplyPoints,
apiCheckout,
apiCancelCheckout,
} from "@/lib/cartApi";
@@ -124,6 +126,28 @@ export function CartProvider({ children }) {
[token, selectedStoreId]
);
const applyPoints = useCallback(
async (useLoyaltyPoints) => {
if (!token || !selectedStoreId) throw new Error("Select a store first");
const updated = await apiApplyPoints(token, selectedStoreId, useLoyaltyPoints);
setCart(updated);
return updated;
},
[token, selectedStoreId]
);
const removeCoupon = useCallback(
async () => {
if (!token || !selectedStoreId) throw new Error("Select a store first");
const updated = await apiRemoveCoupon(token, selectedStoreId);
setCart(updated);
return updated;
},
[token, selectedStoreId]
);
const checkout = useCallback(
async () => {
if (!token || !selectedStoreId) throw new Error("Select a store first");
@@ -159,6 +183,8 @@ export function CartProvider({ children }) {
removeItem,
clearCart,
applyCoupon,
applyPoints,
removeCoupon,
checkout,
cancelCheckout,
refreshCart,

View File

@@ -73,6 +73,24 @@ export async function apiApplyCoupon(token, storeId, couponCode) {
return handleResponse(res);
}
export async function apiApplyPoints(token, storeId, useLoyaltyPoints) {
const res = await fetch(`${BASE}/apply-points?storeId=${storeId}&useLoyaltyPoints=${useLoyaltyPoints}`, {
method: "POST",
headers: authHeaders(token),
});
return handleResponse(res);
}
export async function apiRemoveCoupon(token, storeId) {
const res = await fetch(`${BASE}/coupon?storeId=${storeId}`, {
method: "DELETE",
headers: authHeaders(token),
});
return handleResponse(res);
}
export async function apiCheckout(token, { storeId }) {
const res = await fetch(`${BASE}/checkout`, {
method: "POST",

View File

@@ -6,7 +6,9 @@ export async function fetchAllPages(urlBuilder) {
while (page < totalPages) {
const res = await fetch(urlBuilder(page));
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
const body = await res.text().catch(() => '')
const detail = body ? `: ${body.slice(0, 200)}` : ''
throw new Error(`HTTP ${res.status}${detail}`)
}
const data = await res.json();

View File

@@ -1,14 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactCompiler: true,
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8080/api/:path*",
},
];
},
devIndicators: false,
};
export default nextConfig;

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
web/public/stores/west.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB