From 1636ba14b671114d9c5f79d5c5eeaabb920e5742 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Wed, 15 Apr 2026 00:54:46 -0600 Subject: [PATCH] Coupon system working properly --- .../backend/controller/CartController.java | 8 ++ .../backend/dto/cart/CartResponse.java | 8 ++ .../petshop/backend/service/CartService.java | 35 +++-- web/app/cart/page.js | 131 +++++++++++++++--- web/app/globals.css | 91 +++++++++++- web/context/CartContext.js | 13 ++ web/lib/cartApi.js | 9 ++ 7 files changed, 263 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/controller/CartController.java b/backend/src/main/java/com/petshop/backend/controller/CartController.java index 6887326c..0e18353a 100644 --- a/backend/src/main/java/com/petshop/backend/controller/CartController.java +++ b/backend/src/main/java/com/petshop/backend/controller/CartController.java @@ -75,6 +75,14 @@ public class CartController { return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode())); } + @DeleteMapping("/coupon") + @PreAuthorize("isAuthenticated()") + public ResponseEntity removeCoupon(@RequestParam Long storeId) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.removeCoupon(userId, storeId)); + } + @PostMapping("/checkout") @PreAuthorize("isAuthenticated()") public ResponseEntity checkout(@Valid @RequestBody CheckoutRequest request) { diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java index 3e0c2919..58ff0444 100644 --- a/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java @@ -14,6 +14,8 @@ public class CartResponse { private BigDecimal pointsDiscountAmount; private BigDecimal totalAmount; private String couponCode; + private String couponDiscountType; + private BigDecimal couponDiscountValue; private Boolean pointsApplied; private Integer availableLoyaltyPoints; private Boolean checkoutPending; @@ -45,6 +47,12 @@ public class CartResponse { public String getCouponCode() { return couponCode; } public void setCouponCode(String couponCode) { this.couponCode = couponCode; } + public String getCouponDiscountType() { return couponDiscountType; } + public void setCouponDiscountType(String couponDiscountType) { this.couponDiscountType = couponDiscountType; } + + public BigDecimal getCouponDiscountValue() { return couponDiscountValue; } + public void setCouponDiscountValue(BigDecimal couponDiscountValue) { this.couponDiscountValue = couponDiscountValue; } + public BigDecimal getPointsDiscountAmount() { return pointsDiscountAmount; } public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { this.pointsDiscountAmount = pointsDiscountAmount; } diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java index 76543163..6c672e41 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -212,6 +212,19 @@ public class CartService { return toResponse(cart); } + @Transactional + public CartResponse removeCoupon(Long userId, Long storeId) { + Cart cart = cartRepository + .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .orElseThrow(() -> new BusinessException("No active cart found")); + + requireNotCheckoutPending(cart); + cart.setCoupon(null); + recalculate(cart); + + return toResponse(cart); + } + @Transactional public CartResponse applyPoints(Long userId, Long storeId, Boolean useLoyaltyPoints) { Cart cart = cartRepository @@ -411,12 +424,10 @@ public class CartService { Coupon coupon = cart.getCoupon(); if (coupon != null) { - if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { + if (isPercentageType(coupon.getDiscountType())) { discount = subtotal.multiply(coupon.getDiscountValue()) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - } - - else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { + } else if (isFixedType(coupon.getDiscountType())) { discount = coupon.getDiscountValue().min(subtotal); } } @@ -442,12 +453,10 @@ public class CartService { Coupon coupon = cart.getCoupon(); if (coupon != null) { - if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { + if (isPercentageType(coupon.getDiscountType())) { discount = subtotal.multiply(coupon.getDiscountValue()) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - } - - else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { + } else if (isFixedType(coupon.getDiscountType())) { discount = coupon.getDiscountValue().min(subtotal); } } @@ -460,6 +469,14 @@ public class CartService { return remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO); } + private boolean isPercentageType(String discountType) { + return "PERCENTAGE".equalsIgnoreCase(discountType) || "PERCENT".equalsIgnoreCase(discountType); + } + + private boolean isFixedType(String discountType) { + return "FIXED".equalsIgnoreCase(discountType) || "FLAT".equalsIgnoreCase(discountType); + } + private BigDecimal calculatePointsDiscount(User user, BigDecimal remainingAmount, boolean pointsApplied) { if (!pointsApplied || user == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) { @@ -509,6 +526,8 @@ public class CartService { response.setPointsDiscountAmount(cart.getPointsDiscountAmount()); response.setTotalAmount(cart.getTotalAmount()); response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null); + response.setCouponDiscountType(cart.getCoupon() != null ? cart.getCoupon().getDiscountType() : null); + response.setCouponDiscountValue(cart.getCoupon() != null ? cart.getCoupon().getDiscountValue() : null); response.setPointsApplied(cart.getPointsApplied()); response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null); response.setCheckoutPending(cart.getCheckoutPending()); diff --git a/web/app/cart/page.js b/web/app/cart/page.js index e555a5b2..f2eb897b 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -84,6 +84,7 @@ export default function CartPage() { removeItem, clearCart, applyCoupon, + removeCoupon, checkout, cancelCheckout, } = useCart(); @@ -91,6 +92,7 @@ export default function CartPage() { const [couponInput, setCouponInput] = useState(""); const [couponError, setCouponError] = useState(null); + const [couponSuccess, setCouponSuccess] = useState(null); const [couponLoading, setCouponLoading] = useState(false); const [checkoutLoading, setCheckoutLoading] = useState(false); @@ -156,15 +158,41 @@ 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 handleRemoveCoupon() { + setCouponLoading(true); + setCouponError(null); + setCouponSuccess(null); + try { + await removeCoupon(); + } + + catch (err) { + setCouponError(err.message); + } + finally { setCouponLoading(false); } @@ -309,32 +337,89 @@ export default function CartPage() { {parseFloat(cart.discountAmount ?? 0) > 0 && (
- Discount {cart.couponCode && `(${cart.couponCode})`} + + 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 && ")"} + −${parseFloat(cart.discountAmount).toFixed(2)}
)}
Total - ${parseFloat(cart.totalAmount ?? 0).toFixed(2)} +
+ {parseFloat(cart.discountAmount ?? 0) > 0 && ( + + ${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)} + + )} + 0 ? "cart-total-discounted" : ""}> + ${parseFloat(cart.totalAmount ?? 0).toFixed(2)} + +
+ {parseFloat(cart.discountAmount ?? 0) > 0 && ( +
+ You save ${parseFloat(cart.discountAmount).toFixed(2)}! +
+ )} +
- setCouponInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleApplyCoupon()} - /> - + {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 ""; + })()} + + +
+ )} +
+ { setCouponInput(e.target.value); setCouponError(null); setCouponSuccess(null); }} + onKeyDown={(e) => e.key === "Enter" && handleApplyCoupon()} + /> + +
+ {cart.couponCode && ( +

Applying a new code will replace the current coupon.

+ )} + {couponSuccess &&

{couponSuccess}

} {couponError &&

{couponError}

}
diff --git a/web/app/globals.css b/web/app/globals.css index 2dbbe14a..922867f4 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2385,13 +2385,83 @@ 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-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; @@ -2417,12 +2487,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; diff --git a/web/context/CartContext.js b/web/context/CartContext.js index 777da6ba..e60c3c70 100644 --- a/web/context/CartContext.js +++ b/web/context/CartContext.js @@ -9,6 +9,7 @@ import { apiRemoveCartItem, apiClearCart, apiApplyCoupon, + apiRemoveCoupon, apiCheckout, apiCancelCheckout, } from "@/lib/cartApi"; @@ -124,6 +125,17 @@ export function CartProvider({ children }) { [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 +171,7 @@ export function CartProvider({ children }) { removeItem, clearCart, applyCoupon, + removeCoupon, checkout, cancelCheckout, refreshCart, diff --git a/web/lib/cartApi.js b/web/lib/cartApi.js index b8d608ce..77997c92 100644 --- a/web/lib/cartApi.js +++ b/web/lib/cartApi.js @@ -73,6 +73,15 @@ export async function apiApplyCoupon(token, storeId, couponCode) { 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",