From dfc3d627c8faf0bf130b4fdf2f9140d816b3dcad Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Wed, 15 Apr 2026 02:44:14 -0600 Subject: [PATCH 1/3] Points now subtract from costs --- .../backend/controller/CartController.java | 10 +++ .../petshop/backend/service/CartService.java | 38 +++++++++-- web/app/cart/page.js | 64 +++++++++++++++++- web/app/globals.css | 65 +++++++++++++++++++ web/context/CartContext.js | 13 ++++ web/lib/cartApi.js | 9 +++ 6 files changed, 192 insertions(+), 7 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 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", From 78a8992f711711a0b0cd893e84f6cc9765e68ad5 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Wed, 15 Apr 2026 02:49:26 -0600 Subject: [PATCH 2/3] Minor change to appointments page --- web/app/globals.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/app/globals.css b/web/app/globals.css index 70647f2d..5004588a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1445,6 +1445,16 @@ body { margin: 0.25rem 0 0; } +.appt-no-slots a { + color: #2563eb; + text-decoration: underline; + font-weight: 500; +} + +.appt-no-slots a:hover { + color: #1d4ed8; +} + .appt-pets-grid { display: flex; flex-wrap: wrap; From c37650d942d2676da96c21a943551bcd7972c35b Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Wed, 15 Apr 2026 06:39:41 -0600 Subject: [PATCH 3/3] Small corrections --- web/app/adopt/page.js | 6 +----- web/app/contact/page.js | 4 ++-- web/app/products/page.js | 5 +---- web/context/AuthContext.js | 21 ++++++++++++++++----- web/next.config.mjs | 1 + 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 3f364f94..7ac2a8eb 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -159,11 +159,7 @@ export default function AdoptPage() { {loading &&

Loading pets...

} {error && ( -
-

Failed to load pets

- {error} -

Make sure the backend is running and try again.

-
+

Unable to load pets, please try again later.

)} {!loading && !error && displayedPets.length === 0 && ( diff --git a/web/app/contact/page.js b/web/app/contact/page.js index c6f4686d..eb8274e9 100644 --- a/web/app/contact/page.js +++ b/web/app/contact/page.js @@ -28,11 +28,11 @@ export default function ContactPage() { const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" }); fetch(`/api/v1/stores?${params}`) .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) throw new Error("Unable to load store, please try again later."); return res.json(); }) .then((data) => setLocations(data.content ?? [])) - .catch((err) => setError(err.message)) + .catch(() => setError("Unable to load store, please try again later.")) .finally(() => setLoading(false)); }, []); diff --git a/web/app/products/page.js b/web/app/products/page.js index 19cf8670..c71504bd 100644 --- a/web/app/products/page.js +++ b/web/app/products/page.js @@ -80,10 +80,7 @@ export default function ProductsPage() { {loading &&

Loading products...

} {error && ( -
-

Failed to load products

- {error} -
+

Unable to load products, please try again later.

)} {!loading && !error && products.length === 0 && ( diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js index 41482412..07119815 100644 --- a/web/context/AuthContext.js +++ b/web/context/AuthContext.js @@ -64,10 +64,15 @@ export function AuthProvider({ children }) { body: JSON.stringify({ username, password }), }); - const data = await res.json(); + let data; + try { + data = await res.json(); + } catch { + throw new Error("Unable to log in, please try again later."); + } if (!res.ok) { - throw new Error(data.message || "Login failed"); + throw new Error(data.message || "Unable to log in, please try again later."); } const jwt = data.token; @@ -85,16 +90,22 @@ export function AuthProvider({ children }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, email, firstName, lastName, phone }), }); - const data = await res.json(); + + let data; + try { + data = await res.json(); + } catch { + throw new Error("Unable to register, please try again later."); + } if (!res.ok) { if (data.errors && typeof data.errors === "object") { const fieldErrors = Object.entries(data.errors) .map(([field, msg]) => `${field}: ${msg}`) .join(", "); - throw new Error(fieldErrors || data.message || "Registration failed"); + throw new Error(fieldErrors || data.message || "Unable to register, please try again later."); } - throw new Error(data.message || "Registration failed"); + throw new Error(data.message || "Unable to register, please try again later."); } const jwt = data.token; diff --git a/web/next.config.mjs b/web/next.config.mjs index 2a1407d5..1e03b788 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -2,6 +2,7 @@ const nextConfig = { output: 'standalone', reactCompiler: true, + devIndicators: false, }; export default nextConfig;