Points now subtract from costs
This commit is contained in:
@@ -75,6 +75,16 @@ public class CartController {
|
|||||||
return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode()));
|
return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/apply-points")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<CartResponse> applyPoints(
|
||||||
|
@RequestParam Long storeId,
|
||||||
|
@RequestParam Boolean useLoyaltyPoints) {
|
||||||
|
Long userId = AuthenticationHelper.getAuthenticatedUserId();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(cartService.applyPoints(userId, storeId, useLoyaltyPoints));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/coupon")
|
@DeleteMapping("/coupon")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ResponseEntity<CartResponse> removeCoupon(@RequestParam Long storeId) {
|
public ResponseEntity<CartResponse> removeCoupon(@RequestParam Long storeId) {
|
||||||
|
|||||||
@@ -257,6 +257,34 @@ public class CartService {
|
|||||||
.setScale(0, RoundingMode.HALF_UP)
|
.setScale(0, RoundingMode.HALF_UP)
|
||||||
.longValue();
|
.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) {
|
if (amountInCents < 50) {
|
||||||
throw new BusinessException("Order total is too low to process payment");
|
throw new BusinessException("Order total is too low to process payment");
|
||||||
}
|
}
|
||||||
@@ -489,10 +517,12 @@ public class CartService {
|
|||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
BigDecimal maxRedeemable = remainingAmount.setScale(0, RoundingMode.DOWN);
|
BigDecimal maxDiscount = BigDecimal.valueOf(wholeDollars);
|
||||||
return BigDecimal.valueOf(wholeDollars)
|
// If points can fully cover the remaining amount, discount the entire total to $0.00
|
||||||
.min(maxRedeemable)
|
if (maxDiscount.compareTo(remainingAmount) >= 0) {
|
||||||
.setScale(2, RoundingMode.HALF_UP);
|
return remainingAmount.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
return maxDiscount.setScale(2, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CartResponse toResponse(Cart cart) {
|
private CartResponse toResponse(Cart cart) {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function CartPage() {
|
|||||||
removeItem,
|
removeItem,
|
||||||
clearCart,
|
clearCart,
|
||||||
applyCoupon,
|
applyCoupon,
|
||||||
|
applyPoints,
|
||||||
removeCoupon,
|
removeCoupon,
|
||||||
checkout,
|
checkout,
|
||||||
cancelCheckout,
|
cancelCheckout,
|
||||||
@@ -98,6 +99,10 @@ export default function CartPage() {
|
|||||||
const [couponSuccess, setCouponSuccess] = useState(null);
|
const [couponSuccess, setCouponSuccess] = useState(null);
|
||||||
const [couponLoading, setCouponLoading] = useState(false);
|
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 [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||||
const [checkoutError, setCheckoutError] = useState(null);
|
const [checkoutError, setCheckoutError] = useState(null);
|
||||||
const [clientSecret, setClientSecret] = useState(null);
|
const [clientSecret, setClientSecret] = useState(null);
|
||||||
@@ -118,6 +123,8 @@ export default function CartPage() {
|
|||||||
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
|
cart.items.forEach((i) => (map[i.cartItemId] = i.quantity));
|
||||||
setLocalQuantities(map);
|
setLocalQuantities(map);
|
||||||
}
|
}
|
||||||
|
// Sync optimistic state back to server truth whenever cart updates
|
||||||
|
setOptimisticPointsApplied(null);
|
||||||
}, [cart]);
|
}, [cart]);
|
||||||
|
|
||||||
// If the cart arrives already locked (e.g. user closed the page mid-checkout)
|
// 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() {
|
async function handleRemoveCoupon() {
|
||||||
setCouponLoading(true);
|
setCouponLoading(true);
|
||||||
setCouponError(null);
|
setCouponError(null);
|
||||||
@@ -213,6 +234,7 @@ export default function CartPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else if (result?.status === "succeeded") {
|
else if (result?.status === "succeeded") {
|
||||||
|
refreshUser().catch(() => {});
|
||||||
setConfirmed(true);
|
setConfirmed(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +363,7 @@ export default function CartPage() {
|
|||||||
{parseFloat(cart.discountAmount ?? 0) > 0 && (
|
{parseFloat(cart.discountAmount ?? 0) > 0 && (
|
||||||
<div className="cart-summary-row cart-summary-discount">
|
<div className="cart-summary-row cart-summary-discount">
|
||||||
<span>
|
<span>
|
||||||
Discount
|
Coupon discount
|
||||||
{cart.couponCode && ` (${cart.couponCode}`}
|
{cart.couponCode && ` (${cart.couponCode}`}
|
||||||
{(() => {
|
{(() => {
|
||||||
const t = cart.couponDiscountType?.toUpperCase();
|
const t = cart.couponDiscountType?.toUpperCase();
|
||||||
@@ -356,15 +378,21 @@ export default function CartPage() {
|
|||||||
<span>−${parseFloat(cart.discountAmount).toFixed(2)}</span>
|
<span>−${parseFloat(cart.discountAmount).toFixed(2)}</span>
|
||||||
</div>
|
</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">
|
<div className="cart-summary-row cart-summary-total">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<div className="cart-total-prices">
|
<div className="cart-total-prices">
|
||||||
{parseFloat(cart.discountAmount ?? 0) > 0 && (
|
{(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) && (
|
||||||
<span className="cart-total-original">
|
<span className="cart-total-original">
|
||||||
${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}
|
${parseFloat(cart.subtotalAmount ?? 0).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={parseFloat(cart.discountAmount ?? 0) > 0 ? "cart-total-discounted" : ""}>
|
<span className={(parseFloat(cart.discountAmount ?? 0) > 0 || parseFloat(cart.pointsDiscountAmount ?? 0) > 0) ? "cart-total-discounted" : ""}>
|
||||||
${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
|
${parseFloat(cart.totalAmount ?? 0).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,6 +410,36 @@ export default function CartPage() {
|
|||||||
</div>
|
</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">
|
<div className="cart-coupon-section">
|
||||||
{cart.couponCode && (
|
{cart.couponCode && (
|
||||||
<div className="cart-coupon-applied">
|
<div className="cart-coupon-applied">
|
||||||
|
|||||||
@@ -2417,6 +2417,71 @@ body {
|
|||||||
text-align: center;
|
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 {
|
.cart-coupon-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
apiClearCart,
|
apiClearCart,
|
||||||
apiApplyCoupon,
|
apiApplyCoupon,
|
||||||
apiRemoveCoupon,
|
apiRemoveCoupon,
|
||||||
|
apiApplyPoints,
|
||||||
apiCheckout,
|
apiCheckout,
|
||||||
apiCancelCheckout,
|
apiCancelCheckout,
|
||||||
} from "@/lib/cartApi";
|
} from "@/lib/cartApi";
|
||||||
@@ -125,6 +126,17 @@ export function CartProvider({ children }) {
|
|||||||
[token, selectedStoreId]
|
[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(
|
const removeCoupon = useCallback(
|
||||||
async () => {
|
async () => {
|
||||||
if (!token || !selectedStoreId) throw new Error("Select a store first");
|
if (!token || !selectedStoreId) throw new Error("Select a store first");
|
||||||
@@ -171,6 +183,7 @@ export function CartProvider({ children }) {
|
|||||||
removeItem,
|
removeItem,
|
||||||
clearCart,
|
clearCart,
|
||||||
applyCoupon,
|
applyCoupon,
|
||||||
|
applyPoints,
|
||||||
removeCoupon,
|
removeCoupon,
|
||||||
checkout,
|
checkout,
|
||||||
cancelCheckout,
|
cancelCheckout,
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export async function apiApplyCoupon(token, storeId, couponCode) {
|
|||||||
return handleResponse(res);
|
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) {
|
export async function apiRemoveCoupon(token, storeId) {
|
||||||
const res = await fetch(`${BASE}/coupon?storeId=${storeId}`, {
|
const res = await fetch(`${BASE}/coupon?storeId=${storeId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
Reference in New Issue
Block a user