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 0e18353a..975d786a 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,16 @@ public class CartController { return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode())); } + @PostMapping("/apply-points") + @PreAuthorize("isAuthenticated()") + public ResponseEntity applyPoints( + @RequestParam Long storeId, + @RequestParam Boolean useLoyaltyPoints) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.applyPoints(userId, storeId, useLoyaltyPoints)); + } + @DeleteMapping("/coupon") @PreAuthorize("isAuthenticated()") public ResponseEntity removeCoupon(@RequestParam Long storeId) { 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 6c672e41..8fd8c053 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -257,6 +257,34 @@ public class CartService { .setScale(0, RoundingMode.HALF_UP) .longValue(); + // Free checkout: total is $0.00, or points are applied and the remaining + // amount is below Stripe's $0.50 minimum (cannot be charged via card) + if (amountInCents == 0 || (amountInCents < 50 && Boolean.TRUE.equals(cart.getPointsApplied()))) { + SaleRequest saleRequest = new SaleRequest(); + saleRequest.setStoreId(cart.getStore().getStoreId()); + saleRequest.setCustomerId(cart.getUser().getId()); + saleRequest.setCartId(cart.getCartId()); + saleRequest.setCouponId(cart.getCoupon() != null ? cart.getCoupon().getCouponId() : null); + saleRequest.setPaymentMethod("Points"); + saleRequest.setChannel("WEBSITE"); + saleRequest.setItems(items.stream() + .map(item -> { + SaleItemRequest sir = new SaleItemRequest(); + sir.setProdId(item.getProduct().getProdId()); + sir.setQuantity(item.getQuantity()); + return sir; + }) + .toList()); + + saleService.createSale(saleRequest); + + cart.setCartStatus("CHECKED_OUT"); + cart.setCheckoutPending(false); + cartRepository.save(cart); + + return new CheckoutResponse(cart.getCartId(), null, BigDecimal.ZERO, "succeeded"); + } + if (amountInCents < 50) { throw new BusinessException("Order total is too low to process payment"); } @@ -489,10 +517,12 @@ public class CartService { return BigDecimal.ZERO; } - BigDecimal maxRedeemable = remainingAmount.setScale(0, RoundingMode.DOWN); - return BigDecimal.valueOf(wholeDollars) - .min(maxRedeemable) - .setScale(2, RoundingMode.HALF_UP); + BigDecimal maxDiscount = BigDecimal.valueOf(wholeDollars); + // If points can fully cover the remaining amount, discount the entire total to $0.00 + if (maxDiscount.compareTo(remainingAmount) >= 0) { + return remainingAmount.setScale(2, RoundingMode.HALF_UP); + } + return maxDiscount.setScale(2, RoundingMode.HALF_UP); } private CartResponse toResponse(Cart cart) { diff --git a/web/app/cart/page.js b/web/app/cart/page.js index e64369be..cb378a25 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -87,6 +87,7 @@ export default function CartPage() { removeItem, clearCart, applyCoupon, + applyPoints, removeCoupon, checkout, cancelCheckout, @@ -98,6 +99,10 @@ export default function CartPage() { 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); @@ -118,6 +123,8 @@ export default function CartPage() { 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) @@ -184,6 +191,20 @@ export default function CartPage() { } } + 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); @@ -213,6 +234,7 @@ export default function CartPage() { } else if (result?.status === "succeeded") { + refreshUser().catch(() => {}); setConfirmed(true); } } @@ -341,7 +363,7 @@ export default function CartPage() { {parseFloat(cart.discountAmount ?? 0) > 0 && (
- Discount + Coupon discount {cart.couponCode && ` (${cart.couponCode}`} {(() => { const t = cart.couponDiscountType?.toUpperCase(); @@ -356,15 +378,21 @@ export default function CartPage() { −${parseFloat(cart.discountAmount).toFixed(2)}
)} + {parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && ( +
+ Loyalty discount ({Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts) + −${parseFloat(cart.pointsDiscountAmount).toFixed(2)} +
+ )}
Total
- {parseFloat(cart.discountAmount ?? 0) > 0 && ( + {(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) && ( ${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)} )} - 0 ? "cart-total-discounted" : ""}> + 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "cart-total-discounted" : ""}> ${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
@@ -382,6 +410,36 @@ export default function CartPage() {
)} + {user?.role === "CUSTOMER" && ( +
+
+ Your points balance: + {cart.availableLoyaltyPoints ?? 0} pts +
+ {(cart.availableLoyaltyPoints ?? 0) < 20 ? ( +

You need at least 20 points to redeem $1.

+ ) : ( + + )} + {pointsError &&

{pointsError}

} + {(optimisticPointsApplied ?? !!cart.pointsApplied) && parseFloat(cart.pointsDiscountAmount ?? 0) > 0 && ( +
+ Applying {Math.round(parseFloat(cart.pointsDiscountAmount) * 20)} pts: + ${parseFloat(cart.pointsDiscountAmount).toFixed(2)} off +
+ )} +
+ )} +
{cart.couponCode && (
diff --git a/web/app/globals.css b/web/app/globals.css index d5b8aa87..70647f2d 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2417,6 +2417,71 @@ body { text-align: center; } +.cart-points-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; +} + +.cart-points-balance-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9rem; + color: #374151; +} + +.cart-points-balance-row strong { + color: #7c3aed; + font-weight: 700; +} + +.cart-points-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: #374151; + cursor: pointer; + user-select: none; +} + +.cart-points-label:has(.cart-points-checkbox:disabled) { + cursor: not-allowed; + opacity: 0.6; +} + +.cart-points-checkbox { + width: 1rem; + height: 1rem; + accent-color: #7c3aed; + cursor: pointer; + flex-shrink: 0; +} + +.cart-points-msg { + font-size: 0.82rem; + color: #6b7280; + margin: 0; + font-style: italic; +} + +.cart-points-applied-detail { + display: flex; + justify-content: space-between; + align-items: center; + background: #f5f3ff; + border: 1px solid #ddd6fe; + border-radius: 6px; + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + color: #5b21b6; + font-weight: 500; +} + .cart-coupon-section { display: flex; flex-direction: column; diff --git a/web/context/CartContext.js b/web/context/CartContext.js index e60c3c70..f1c76220 100644 --- a/web/context/CartContext.js +++ b/web/context/CartContext.js @@ -10,6 +10,7 @@ import { apiClearCart, apiApplyCoupon, apiRemoveCoupon, + apiApplyPoints, apiCheckout, apiCancelCheckout, } from "@/lib/cartApi"; @@ -125,6 +126,17 @@ export function CartProvider({ children }) { [token, selectedStoreId] ); + const applyPoints = useCallback( + async (useLoyaltyPoints) => { + if (!token || !selectedStoreId) throw new Error("Select a store first"); + const updated = await apiApplyPoints(token, selectedStoreId, useLoyaltyPoints); + setCart(updated); + + return updated; + }, + [token, selectedStoreId] + ); + const removeCoupon = useCallback( async () => { if (!token || !selectedStoreId) throw new Error("Select a store first"); @@ -171,6 +183,7 @@ export function CartProvider({ children }) { removeItem, clearCart, applyCoupon, + applyPoints, removeCoupon, checkout, cancelCheckout, diff --git a/web/lib/cartApi.js b/web/lib/cartApi.js index 77997c92..4e2a7a8a 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 apiApplyPoints(token, storeId, useLoyaltyPoints) { + const res = await fetch(`${BASE}/apply-points?storeId=${storeId}&useLoyaltyPoints=${useLoyaltyPoints}`, { + method: "POST", + headers: authHeaders(token), + }); + + return handleResponse(res); +} + export async function apiRemoveCoupon(token, storeId) { const res = await fetch(`${BASE}/coupon?storeId=${storeId}`, { method: "DELETE",