Merge pull request #307 from RecentRunner/web-v2

merge web-v2
This commit is contained in:
2026-04-15 07:07:34 -06:00
committed by GitHub
11 changed files with 223 additions and 23 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -167,11 +167,7 @@ export default function AdoptPage() {
{loading && <p className="adopt-status-msg">Loading pets...</p>} {loading && <p className="adopt-status-msg">Loading pets...</p>}
{error && ( {error && (
<div className="adopt-error-box"> <p className="adopt-status-msg">Unable to load pets, please try again later.</p>
<p className="adopt-error-title">Failed to load pets</p>
<code className="adopt-error-detail">{error}</code>
<p className="adopt-error-hint">Make sure the backend is running and try again.</p>
</div>
)} )}
{!loading && !error && displayedPets.length === 0 && ( {!loading && !error && displayedPets.length === 0 && (

View File

@@ -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">

View File

@@ -28,11 +28,11 @@ export default function ContactPage() {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" }); const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`) fetch(`/api/v1/stores?${params}`)
.then((res) => { .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(); return res.json();
}) })
.then((data) => setLocations(data.content ?? [])) .then((data) => setLocations(data.content ?? []))
.catch((err) => setError(err.message)) .catch(() => setError("Unable to load store, please try again later."))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);

View File

@@ -1445,6 +1445,16 @@ body {
margin: 0.25rem 0 0; 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 { .appt-pets-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2417,6 +2427,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;

View File

@@ -87,10 +87,7 @@ export default function ProductsPage() {
{loading && <p className="adopt-status-msg">Loading products...</p>} {loading && <p className="adopt-status-msg">Loading products...</p>}
{error && ( {error && (
<div className="adopt-error-box"> <p className="adopt-status-msg">Unable to load products, please try again later.</p>
<p className="adopt-error-title">Failed to load products</p>
<code className="adopt-error-detail">{error}</code>
</div>
)} )}
{!loading && !error && products.length === 0 && ( {!loading && !error && products.length === 0 && (

View File

@@ -64,10 +64,15 @@ export function AuthProvider({ children }) {
body: JSON.stringify({ username, password }), 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) { 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; const jwt = data.token;
@@ -85,16 +90,22 @@ export function AuthProvider({ children }) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, email, firstName, lastName, phone }), 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 (!res.ok) {
if (data.errors && typeof data.errors === "object") { if (data.errors && typeof data.errors === "object") {
const fieldErrors = Object.entries(data.errors) const fieldErrors = Object.entries(data.errors)
.map(([field, msg]) => `${field}: ${msg}`) .map(([field, msg]) => `${field}: ${msg}`)
.join(", "); .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; const jwt = data.token;

View File

@@ -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,

View File

@@ -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",

View File

@@ -2,6 +2,7 @@
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
reactCompiler: true, reactCompiler: true,
devIndicators: false,
}; };
export default nextConfig; export default nextConfig;