Files
group-2-threaded-project-pe…/web/app/cart/page.js
2026-04-15 02:44:14 -06:00

529 lines
18 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="cart-payment-form">
<h3 className="cart-payment-title">Payment Details</h3>
<p className="cart-payment-total">
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
</p>
<PaymentElement />
{payError && <p className="cart-error-msg">{payError}</p>}
<div className="cart-payment-actions">
<button
className="cart-pay-btn"
type="button"
onClick={handlePay}
disabled={paying || !stripe}
>
{paying ? "Processing…" : `Pay $${parseFloat(totalAmount).toFixed(2)}`}
</button>
<button className="cart-cancel-btn" 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);
}
// 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)
// and there is no active Stripe session, release the lock automatically.
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="cart-page"><p className="cart-status-msg">Loading</p></main>;
}
if (!user) return null;
if (confirmed) {
return (
<main className="cart-page">
<div className="cart-confirmation">
<div className="cart-confirmation-icon"></div>
<h2 className="cart-confirmation-title">Order Confirmed!</h2>
<p className="cart-confirmation-body">
Thank you for your purchase. Your order has been placed successfully.
</p>
<button className="cart-continue-btn" type="button" onClick={() => router.push("/products")}>
Continue Shopping
</button>
</div>
</main>
);
}
if (!selectedStoreId) {
return (
<main className="cart-page">
<p className="cart-status-msg">Please select a store from the navigation bar to view your cart.</p>
</main>
);
}
const items = cart?.items ?? [];
return (
<main className="cart-page">
<h1 className="cart-title">Your Cart</h1>
{cartError && <p className="cart-error-msg">{cartError}</p>}
{items.length === 0 && !cartError && (
<div className="cart-empty">
<p className="cart-empty-msg">Your cart is empty.</p>
<button className="cart-continue-btn" type="button" onClick={() => router.push("/products")}>
Browse Products
</button>
</div>
)}
{items.length > 0 && (
<div className="cart-layout">
<div className="cart-items-section">
{items.map((item) => (
<div key={item.cartItemId} className="cart-item-row">
<img
src={item.imageUrl || "/images/pet-placeholder.png"}
alt={item.prodName}
className="cart-item-img"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
<div className="cart-item-details">
<p className="cart-item-name">{item.prodName}</p>
<p className="cart-item-unit-price">${parseFloat(item.unitPrice).toFixed(2)} each</p>
</div>
<div className="cart-item-qty-controls">
<button
className="cart-qty-btn"
type="button"
onClick={() =>
handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) - 1)
}
>
</button>
<span className="cart-qty-val">{localQuantities[item.cartItemId] ?? item.quantity}</span>
<button
className="cart-qty-btn"
type="button"
onClick={() =>
handleQuantityChange(item.cartItemId, (localQuantities[item.cartItemId] ?? item.quantity) + 1)
}
>
+
</button>
</div>
<p className="cart-item-line-total">
${(parseFloat(item.unitPrice) * (localQuantities[item.cartItemId] ?? item.quantity)).toFixed(2)}
</p>
<button
className="cart-item-remove-btn"
type="button"
onClick={() => handleRemove(item.cartItemId)}
>
</button>
</div>
))}
<button className="cart-clear-btn" type="button" onClick={clearCart}>
Clear Cart
</button>
</div>
<aside className="cart-summary">
<h2 className="cart-summary-title">Order Summary</h2>
<div className="cart-summary-row">
<span>Subtotal</span>
<span>${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}</span>
</div>
{parseFloat(cart.discountAmount ?? 0) > 0 && (
<div className="cart-summary-row cart-summary-discount">
<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>
<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">
{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>
{checkoutError && <p className="cart-error-msg">{checkoutError}</p>}
{!clientSecret && (
<button
className="cart-checkout-btn"
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>
);
}