merge main into websitefinal
This commit is contained in:
@@ -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
17
web/Dockerfile
Normal 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"]
|
||||
@@ -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
26
web/app/api/[...path]/route.js
Normal file
26
web/app/api/[...path]/route.js
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: Mon–Sat, 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'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) => {
|
||||
|
||||
92
web/app/forgot-password/page.js
Normal file
92
web/app/forgot-password/page.js
Normal 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'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'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,
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -82,6 +82,11 @@ function LoginPage() {
|
||||
Don'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>
|
||||
);
|
||||
|
||||
@@ -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'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'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'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're a first-time pet owner or a seasoned animal lover, we're here to help every step of the way.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
132
web/app/reset-password/page.js
Normal file
132
web/app/reset-password/page.js
Normal 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,
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
web/public/stores/downtown.webp
Normal file
BIN
web/public/stores/downtown.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
web/public/stores/north.webp
Normal file
BIN
web/public/stores/north.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
web/public/stores/west.webp
Normal file
BIN
web/public/stores/west.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Reference in New Issue
Block a user