Files
group-2-threaded-project-pe…/web/app/cart/page.js
augmentedpotato 79c42574f6 Styling refactor
2026-04-18 16:22:38 -06:00

522 lines
21 KiB
JavaScript
Raw 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.
"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";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
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);
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>
<div className="bg-[#fffbeb] border border-[#fde68a] rounded-lg px-4 py-3 text-[0.82rem] text-[#854d0e] flex flex-col gap-1">
<div><strong>Demo mode</strong> no real charge. Use test card: <span className="font-mono font-bold">4242 4242 4242 4242</span> · any future date · any 3-digit CVC</div>
</div>
<PaymentElement />
{payError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{payError}</p>}
<div className="flex gap-3 mt-2">
<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>
);
}
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({});
useEffect(() => {
if (!authLoading && !user) {
router.push("/login");
}
}, [authLoading, user, router]);
useEffect(() => {
if (cart?.items) {
const map = {};
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
setLocalQuantities(map);
}
setOptimisticPointsApplied(null);
}, [cart]);
useEffect(() => {
if (cart?.checkoutPending && !clientSecret) {
cancelCheckout().catch(() => {});
}
}, [cart?.checkoutPending, clientSecret, cancelCheckout]);
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 {}
}
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);
}
}
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 }}>
<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>
);
}