Files

533 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Author: Shiv
// Date: April 2026
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useCart } from "@/context/CartContext";
import { loadStripe } from "@stripe/stripe-js";
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
import { apiCompleteCheckout } from "@/lib/cartApi";
//Initializes Stripe with the publishable key
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
//Stripe payment form shown after the user clicks checkout
function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
const stripe = useStripe();
const elements = useElements();
const { token } = useAuth();
const [paying, setPaying] = useState(false);
const [payError, setPayError] = useState(null);
//Confirms the payment with Stripe, then tells the backend to complete the order
async function handlePay(e) {
e.preventDefault();
if (!stripe || !elements) return;
setPaying(true);
setPayError(null);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: window.location.origin + "/cart/confirmation" },
redirect: "if_required",
});
if (error) {
setPayError(error.message);
setPaying(false);
}
else {
const paymentIntentId = clientSecret.split("_secret_")[0];
try {
await apiCompleteCheckout(token, paymentIntentId);
} catch {
setPayError("Order confirmation failed. Please contact support.");
setPaying(false);
return;
}
onSuccess();
}
}
return (
<div className="flex flex-col gap-4">
<h3 className="text-lg font-bold text-[#222] m-0">Payment Details</h3>
<p className="text-[0.95rem] text-[#555] m-0">
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
</p>
<PaymentElement />
{payError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{payError}</p>}
<div className="flex gap-3 mt-2">
<button
className="flex-1 py-3 bg-[#e68672] text-white border-none rounded-lg font-bold cursor-pointer hover:bg-[#d4705e] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
onClick={handlePay}
disabled={paying || !stripe}
>
{paying ? "Processing…" : `Pay $${parseFloat(totalAmount).toFixed(2)}`}
</button>
<button className="px-4 py-3 border border-[#ddd] rounded-lg bg-white text-[#555] cursor-pointer hover:border-[#aaa] transition-colors" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
}
//Cart page - shows items, coupons, loyalty points, order summary, and checkout
export default function CartPage() {
const { user, loading: authLoading, refreshUser } = useAuth();
const {
cart,
cartLoading,
cartError,
selectedStoreId,
updateItem,
removeItem,
clearCart,
applyCoupon,
applyPoints,
removeCoupon,
checkout,
cancelCheckout,
} = useCart();
const router = useRouter();
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);
const [checkoutTotal, setCheckoutTotal] = useState(null);
const [confirmed, setConfirmed] = useState(false);
const [localQuantities, setLocalQuantities] = useState({});
//Redirect unauthenticated users to login
useEffect(() => {
if (!authLoading && !user) {
router.push("/login");
}
}, [authLoading, user, router]);
//Sync local quantity inputs whenever the cart updates from the server
useEffect(() => {
if (cart?.items) {
const map = {};
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
setLocalQuantities(map);
}
setOptimisticPointsApplied(null);
}, [cart]);
//Cancel any leftover pending checkout if the page loads without a client secret
useEffect(() => {
if (cart?.checkoutPending && !clientSecret) {
cancelCheckout().catch(() => {});
}
}, [cart?.checkoutPending, clientSecret, cancelCheckout]);
//Updates item quantity and rolls back the change if the request fails
async function handleQuantityChange(cartItemId, newQty) {
if (newQty < 1) return;
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty }));
try {
await updateItem(cartItemId, newQty);
}
catch {
if (cart?.items) {
const original = cart.items.find((i) => i.cartItemId === cartItemId);
if (original) {
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: original.quantity }));
}
}
}
}
async function handleRemove(cartItemId) {
try {
await removeItem(cartItemId);
}
catch {}
}
//Applies the typed coupon code and shows the discount type and amount
async function handleApplyCoupon() {
if (!couponInput.trim()) return;
setCouponLoading(true);
setCouponError(null);
setCouponSuccess(null);
try {
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);
}
}
//Starts checkout
//Either gets a Stripe client secret for payment or marks the order complete directly
async function handleCheckout() {
if (!cart?.items?.length) return;
setCheckoutLoading(true);
setCheckoutError(null);
try {
const result = await checkout();
if (result?.clientSecret) {
setClientSecret(result.clientSecret);
setCheckoutTotal(result.totalAmount);
}
else if (result?.status === "succeeded") {
refreshUser().catch(() => {});
setConfirmed(true);
}
}
catch (err) {
setCheckoutError(err.message);
}
finally {
setCheckoutLoading(false);
}
}
if (authLoading || cartLoading) {
return (
<main className="min-h-[calc(100vh-70px)] flex items-center justify-center">
<p className="text-[#666] text-[1.1rem]">Loading</p>
</main>
);
}
if (!user) return null;
if (confirmed) {
return (
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
<div className="text-center py-16 flex flex-col items-center gap-4">
<div className="text-5xl"></div>
<h2 className="text-2xl font-bold text-[#222]">Order Confirmed!</h2>
<p className="text-[#666]">Thank you for your purchase. Your order has been placed successfully.</p>
<button className="px-6 py-3 bg-[#e68672] text-white rounded-lg font-semibold cursor-pointer hover:bg-[#d4705e] border-none transition-colors" type="button" onClick={() => router.push("/products")}>
Continue Shopping
</button>
</div>
</main>
);
}
if (!selectedStoreId) {
return (
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
<p className="text-center text-[#666] py-12 text-[1.1rem]">Please select a store from the navigation bar to view your cart.</p>
</main>
);
}
const items = cart?.items ?? [];
return (
<main className="min-h-[calc(100vh-70px)] max-w-[1100px] mx-auto px-8 py-10">
<h1 className="text-3xl font-bold text-[#222] mb-8">Your Cart</h1>
{cartError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] mb-4">{cartError}</p>}
{items.length === 0 && !cartError && (
<div className="text-center py-16 flex flex-col items-center gap-4">
<p className="text-[#666] text-[1.1rem]">Your cart is empty.</p>
<button className="px-6 py-3 bg-[#e68672] text-white rounded-lg font-semibold cursor-pointer hover:bg-[#d4705e] border-none transition-colors" type="button" onClick={() => router.push("/products")}>
Browse Products
</button>
</div>
)}
{items.length > 0 && (
<div className="flex gap-8 items-start max-[900px]:flex-col">
{/* Items list */}
<div className="flex-1 flex flex-col gap-4">
{items.map((item) => (
<div key={item.cartItemId} className="flex items-center gap-4 bg-white rounded-xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)]">
<img
src={item.imageUrl || "/images/pet-placeholder.png"}
alt={item.prodName}
className="w-16 h-16 object-cover rounded-lg"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
<div className="flex-1">
<p className="font-semibold text-[#222] text-[0.95rem] m-0 mb-1">{item.prodName}</p>
<p className="text-[0.85rem] text-[#888] m-0">${parseFloat(item.unitPrice).toFixed(2)} each</p>
</div>
<div className="flex items-center gap-2">
<button
className="w-8 h-8 flex items-center justify-center rounded-md border border-[#ddd] bg-white cursor-pointer hover:border-[#e68672] transition-colors text-[#333]"
type="button"
onClick={() => handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) - 1)}
>
</button>
<span className="w-8 text-center font-semibold">{localQuantities[item.cartItemId] ?? item.quantity}</span>
<button
className="w-8 h-8 flex items-center justify-center rounded-md border border-[#ddd] bg-white cursor-pointer hover:border-[#e68672] transition-colors text-[#333]"
type="button"
onClick={() => handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) + 1)}
>
+
</button>
</div>
<p className="font-bold text-[#333] min-w-[4rem] text-right">
${(parseFloat(item.unitPrice) * (localQuantities[item.cartItemId] ?? item.quantity)).toFixed(2)}
</p>
<button
className="w-8 h-8 flex items-center justify-center rounded-md border-none bg-transparent cursor-pointer text-[#aaa] hover:text-[#c0392b] transition-colors"
type="button"
onClick={() => handleRemove(item.cartItemId)}
>
</button>
</div>
))}
<div className="flex justify-end">
<button className="px-4 py-2 rounded-lg border border-[#ddd] bg-white text-[#666] text-[0.85rem] cursor-pointer hover:border-[#aaa] transition-colors" type="button" onClick={clearCart}>
Clear Cart
</button>
</div>
</div>
{/* Summary aside */}
<aside className="w-80 max-[900px]:w-full bg-white rounded-xl shadow-[0_2px_12px_rgba(0,0,0,0.08)] p-6 sticky top-24 flex flex-col gap-3">
<h2 className="text-xl font-bold text-[#222] m-0 mb-2">Order Summary</h2>
<div className="flex items-center justify-between text-[0.9rem] text-[#555]">
<span>Subtotal</span>
<span>${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}</span>
</div>
{parseFloat(cart.discountAmount ?? 0) > 0 && (
<div className="flex items-center justify-between text-[0.9rem] text-[#16a34a]">
<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="flex items-center justify-between text-[0.9rem] text-[#16a34a]">
<span>Loyalty discount ({Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts)</span>
<span>${parseFloat(cart.pointsDiscountAmount).toFixed(2)}</span>
</div>
)}
<div className="flex items-center justify-between border-t border-[#eee] pt-3 font-bold text-[1rem] text-[#222]">
<span>Total</span>
<div className="flex flex-col items-end gap-1">
{(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) && (
<span className="line-through text-[#aaa] text-[0.85rem] font-normal">
${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}
</span>
)}
<span className={(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "text-[#e68672] text-[1.1rem]" : ""}>
${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
</span>
</div>
</div>
{parseFloat(cart.discountAmount ?? 0) > 0 && (
<div className="bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-3 py-2 text-[0.85rem] font-semibold text-center">
You save ${parseFloat(cart.discountAmount).toFixed(2)}!
</div>
)}
{user?.role === "CUSTOMER" && (
<div className="text-[0.85rem] text-[#555] bg-[#fffbeb] rounded-lg px-3 py-2">
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="border-t border-[#eee] pt-3 flex flex-col gap-2">
<div className="flex justify-between text-[0.85rem] text-[#555]">
<span>Your points balance:</span>
<strong>{cart.availableLoyaltyPoints ?? 0} pts</strong>
</div>
{(cart.availableLoyaltyPoints ?? 0) < 20 ? (
<p className="text-[0.8rem] text-[#888] m-0">You need at least 20 points to redeem $1.</p>
) : (
<label className="flex items-center gap-2 text-[0.85rem] cursor-pointer">
<input
type="checkbox"
className="accent-[#e68672]"
checked={optimisticPointsApplied !== null ? optimisticPointsApplied : !!cart.pointsApplied}
disabled={pointsLoading}
onChange={(e) => handleTogglePoints(e.target.checked)}
/>
Use loyalty points for this purchase
</label>
)}
{pointsError && <p className="text-[0.8rem] text-[#dc2626] m-0">{pointsError}</p>}
{(optimisticPointsApplied ?? !!cart.pointsApplied) && parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && (
<div className="flex justify-between text-[0.82rem] text-[#16a34a] font-semibold">
<span>Applying {Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts:</span>
<span>${parseFloat(cart.pointsDiscountAmount).toFixed(2)} off</span>
</div>
)}
</div>
)}
{/* Coupon section */}
<div className="border-t border-[#eee] pt-3 flex flex-col gap-2">
{cart.couponCode && (
<div className="flex items-center justify-between">
<span className="bg-[#e68672]/10 text-[#e68672] text-[0.82rem] font-semibold rounded-full px-3 py-1">
{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="w-6 h-6 flex items-center justify-center rounded-full border-none bg-transparent cursor-pointer text-[#aaa] hover:text-[#c0392b] transition-colors text-[0.75rem]"
type="button"
onClick={handleRemoveCoupon}
disabled={couponLoading}
title="Remove coupon"
>
</button>
</div>
)}
<div className="flex gap-2">
<input
className="flex-1 px-3 py-2 border border-[#ddd] rounded-lg text-[0.85rem] outline-none focus:border-[#e68672] transition-colors"
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="px-4 py-2 bg-[#e68672] text-white rounded-lg text-[0.85rem] font-semibold border-none cursor-pointer hover:bg-[#d4705e] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
onClick={handleApplyCoupon}
disabled={couponLoading || !couponInput.trim()}
>
{couponLoading ? "…" : "Apply"}
</button>
</div>
{cart.couponCode && (
<p className="text-[0.78rem] text-[#888] m-0">Applying a new code will replace the current coupon.</p>
)}
{couponSuccess && <p className="text-[0.85rem] text-[#16a34a] m-0">{couponSuccess}</p>}
{couponError && <p className="text-[0.85rem] text-[#c0392b] m-0">{couponError}</p>}
</div>
{checkoutError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{checkoutError}</p>}
{!clientSecret && (
<button
className="mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] disabled:opacity-60 disabled:cursor-not-allowed w-full"
type="button"
onClick={handleCheckout}
disabled={checkoutLoading || items.length === 0}
>
{checkoutLoading ? "Processing…" : "Checkout"}
</button>
)}
{clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret, wallets: { link: "never" } }}>
<PaymentForm
clientSecret={clientSecret}
totalAmount={checkoutTotal ?? cart.totalAmount}
onSuccess={() => {
setClientSecret(null);
setConfirmed(true);
refreshUser().catch(() => {});
}}
onCancel={async () => {
await cancelCheckout().catch(() => {});
setClientSecret(null);
}}
/>
</Elements>
)}
</aside>
</div>
)}
</main>
);
}