diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index 4e9939c8..693d0171 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -267,7 +267,8 @@ public class AuthController { user.getRole().name(), customerId, primaryStore != null ? primaryStore.getStoreId() : null, - primaryStore != null ? primaryStore.getStoreName() : null + primaryStore != null ? primaryStore.getStoreName() : null, + user.getLoyaltyPoints() ); } 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/auth/UserInfoResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java index e88c272d..74dbc039 100644 --- a/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java @@ -15,11 +15,12 @@ public class UserInfoResponse { private Long customerId; private Long storeId; private String storeName; + private Integer loyaltyPoints; public UserInfoResponse() { } - public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) { + public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName, Integer loyaltyPoints) { this.id = id; this.username = username; this.firstName = firstName; @@ -32,6 +33,7 @@ public class UserInfoResponse { this.customerId = customerId; this.storeId = storeId; this.storeName = storeName; + this.loyaltyPoints = loyaltyPoints; } public Long getId() { @@ -131,6 +133,14 @@ public class UserInfoResponse { this.storeName = storeName; } + public Integer getLoyaltyPoints() { + return loyaltyPoints; + } + + public void setLoyaltyPoints(Integer loyaltyPoints) { + this.loyaltyPoints = loyaltyPoints; + } + @Override public boolean equals(Object o) { if (this == o) return true; 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 344e50e7..e64369be 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -77,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, @@ -87,6 +87,7 @@ export default function CartPage() { removeItem, clearCart, applyCoupon, + removeCoupon, checkout, cancelCheckout, } = useCart(); @@ -94,6 +95,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); @@ -159,15 +161,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); } @@ -312,32 +340,95 @@ 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)}! +
+ )} + + {user?.role === "CUSTOMER" && ( +
+ ⭐ Earn {Math.floor(parseFloat(cart.totalAmount ?? 0))} loyalty point{Math.floor(parseFloat(cart.totalAmount ?? 0)) !== 1 ? "s" : ""} with this purchase +
+ )} +
- 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}

}
@@ -362,6 +453,7 @@ export default function CartPage() { onSuccess={() => { setClientSecret(null); setConfirmed(true); + refreshUser().catch(() => {}); }} onCancel={async () => { await cancelCheckout().catch(() => {}); diff --git a/web/app/globals.css b/web/app/globals.css index 67349f25..d5b8aa87 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2379,13 +2379,93 @@ 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-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; @@ -2411,12 +2491,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/app/profile/page.js b/web/app/profile/page.js index ddb23d62..a96d9f59 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -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 ( 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",