From 4d91d8b331dfbb430de8ce9d963ffc3fc34defc4 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 9 Apr 2026 22:27:03 -0600 Subject: [PATCH] Stripe Payment --- backend/Dockerfile | 6 +- backend/docker-compose.yml | 1 + backend/pom.xml | 6 + .../backend/controller/CartController.java | 84 +++ .../backend/controller/StoreController.java | 3 +- .../backend/dto/cart/AddToCartRequest.java | 43 ++ .../backend/dto/cart/ApplyCouponRequest.java | 17 + .../backend/dto/cart/CartItemResponse.java | 90 +++ .../backend/dto/cart/CartResponse.java | 43 ++ .../backend/dto/cart/CheckoutRequest.java | 24 + .../backend/dto/cart/CheckoutResponse.java | 57 ++ .../dto/cart/UpdateCartItemRequest.java | 32 ++ .../repository/CartItemRepository.java | 5 + .../backend/repository/CartRepository.java | 7 + .../backend/repository/CouponRepository.java | 2 + .../petshop/backend/service/CartService.java | 301 ++++++++++ backend/src/main/resources/application.yml | 3 + web/app/cart/page.js | 356 ++++++++++++ web/app/globals.css | 524 ++++++++++++++++++ web/components/ClientProviders.js | 9 +- web/components/Navigation.js | 46 +- web/components/ProductCard.js | 109 +++- web/context/CartContext.js | 171 ++++++ web/lib/cartApi.js | 84 +++ web/package-lock.json | 30 +- web/package.json | 2 + 26 files changed, 2022 insertions(+), 33 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/CartController.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/AddToCartRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/ApplyCouponRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/CartItemResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/CheckoutRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/CheckoutResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/UpdateCartItemRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/service/CartService.java create mode 100644 web/app/cart/page.js create mode 100644 web/context/CartContext.js create mode 100644 web/lib/cartApi.js diff --git a/backend/Dockerfile b/backend/Dockerfile index 95c76bc2..08a06773 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,13 @@ # Build -FROM maven:3.9-eclipse-temurin-17 AS build +FROM maven:3.9-eclipse-temurin-25-alpine AS build WORKDIR /app COPY pom.xml . COPY src ./src RUN mvn -q -DskipTests package # Run -FROM eclipse-temurin:17-jre +FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 1966e7e6..4d3b0da4 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -26,6 +26,7 @@ services: SPRING_DATASOURCE_PASSWORD: petshop # Change this in real use (must be at least 32 characters) JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256 + STRIPE_SECRET_KEY: sk_test_51TK18lFQ95OLlFb7XuwaVRxK2w9CNfeCJMhJt76mGvhRp84ddhX62wiJAcU7jMEP0GodH8aoFx0BZFI3tuf8tIiC00aaW1xQJT ports: - "8080:8080" depends_on: diff --git a/backend/pom.xml b/backend/pom.xml index e1c93bd0..9f2157b4 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -90,6 +90,12 @@ 3.0.1 + + com.stripe + stripe-java + 25.3.0 + + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/petshop/backend/controller/CartController.java b/backend/src/main/java/com/petshop/backend/controller/CartController.java new file mode 100644 index 00000000..e43cce6e --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/CartController.java @@ -0,0 +1,84 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.cart.*; +import com.petshop.backend.service.CartService; +import com.petshop.backend.util.AuthenticationHelper; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/cart") +public class CartController { + + private final CartService cartService; + + public CartController(CartService cartService) { + this.cartService = cartService; + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity getActiveCart(@RequestParam Long storeId) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + CartResponse cart = cartService.getActiveCart(userId, storeId); + + if (cart == null) { + + return ResponseEntity.noContent().build(); + } + + return ResponseEntity.ok(cart); + } + + @PostMapping("/add") + @PreAuthorize("isAuthenticated()") + public ResponseEntity addItem(@Valid @RequestBody AddToCartRequest request) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.addItem(userId, request)); + } + + @PutMapping("/update") + @PreAuthorize("isAuthenticated()") + public ResponseEntity updateItem(@Valid @RequestBody UpdateCartItemRequest request) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.updateItem(userId, request)); + } + + @DeleteMapping("/remove/{cartItemId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity removeItem(@PathVariable Long cartItemId) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.removeItem(userId, cartItemId)); + } + + @DeleteMapping("/clear") + @PreAuthorize("isAuthenticated()") + public ResponseEntity clearCart(@RequestParam Long storeId) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + cartService.clearCart(userId, storeId); + + return ResponseEntity.noContent().build(); + } + + @PostMapping("/apply-coupon") + @PreAuthorize("isAuthenticated()") + public ResponseEntity applyCoupon( + @RequestParam Long storeId, + @Valid @RequestBody ApplyCouponRequest request) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode())); + } + + @PostMapping("/checkout") + @PreAuthorize("isAuthenticated()") + public ResponseEntity checkout(@Valid @RequestBody CheckoutRequest request) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.checkout(userId, request.getStoreId(), request.getPaymentMethodId()));} +} diff --git a/backend/src/main/java/com/petshop/backend/controller/StoreController.java b/backend/src/main/java/com/petshop/backend/controller/StoreController.java index 58110d7e..0c4a70c0 100644 --- a/backend/src/main/java/com/petshop/backend/controller/StoreController.java +++ b/backend/src/main/java/com/petshop/backend/controller/StoreController.java @@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/stores") -@PreAuthorize("hasRole('ADMIN')") public class StoreController { private final StoreService storeService; @@ -24,6 +23,7 @@ public class StoreController { } @GetMapping + @PreAuthorize("isAuthenticated()") public ResponseEntity> getAllStores( @RequestParam(required = false) String q, Pageable pageable) { @@ -31,6 +31,7 @@ public class StoreController { } @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") public ResponseEntity getStoreById(@PathVariable Long id) { return ResponseEntity.ok(storeService.getStoreById(id)); } diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/AddToCartRequest.java b/backend/src/main/java/com/petshop/backend/dto/cart/AddToCartRequest.java new file mode 100644 index 00000000..6075ad4c --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/AddToCartRequest.java @@ -0,0 +1,43 @@ +package com.petshop.backend.dto.cart; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class AddToCartRequest { + + @NotNull + private Long prodId; + + @NotNull + private Long storeId; + + @NotNull + @Min(1) + private Integer quantity; + + public Long getProdId() { + + return prodId; + } + + public void setProdId(Long prodId) { + this.prodId = prodId; + } + + public Long getStoreId() { + + return storeId; + } + + public void setStoreId(Long storeId) { + this.storeId = storeId; + } + + public Integer getQuantity() { + + return quantity; + } + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/ApplyCouponRequest.java b/backend/src/main/java/com/petshop/backend/dto/cart/ApplyCouponRequest.java new file mode 100644 index 00000000..290d4434 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/ApplyCouponRequest.java @@ -0,0 +1,17 @@ +package com.petshop.backend.dto.cart; + +import jakarta.validation.constraints.NotBlank; + +public class ApplyCouponRequest { + + @NotBlank + private String couponCode; + + public String getCouponCode() { + return couponCode; + } + + public void setCouponCode(String couponCode) { + this.couponCode = couponCode; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/CartItemResponse.java b/backend/src/main/java/com/petshop/backend/dto/cart/CartItemResponse.java new file mode 100644 index 00000000..6b13e1bc --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CartItemResponse.java @@ -0,0 +1,90 @@ +package com.petshop.backend.dto.cart; + +import java.math.BigDecimal; + +public class CartItemResponse { + + private Long cartItemId; + private Long prodId; + private String prodName; + private String imageUrl; + private BigDecimal unitPrice; + private Integer quantity; + private BigDecimal lineTotal; + + public CartItemResponse() { + } + + public CartItemResponse(Long cartItemId, Long prodId, String prodName, String imageUrl, + BigDecimal unitPrice, Integer quantity, BigDecimal lineTotal) { + this.cartItemId = cartItemId; + this.prodId = prodId; + this.prodName = prodName; + this.imageUrl = imageUrl; + this.unitPrice = unitPrice; + this.quantity = quantity; + this.lineTotal = lineTotal; + } + + public Long getCartItemId() { + + return cartItemId; + } + + public void setCartItemId(Long cartItemId) { + this.cartItemId = cartItemId; + } + + public Long getProdId() { + + return prodId; + } + + public void setProdId(Long prodId) { + this.prodId = prodId; + } + + public String getProdName() { + + return prodName; + } + + public void setProdName(String prodName) { + this.prodName = prodName; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public BigDecimal getUnitPrice() { + + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + public Integer getQuantity() { + + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + public BigDecimal getLineTotal() { + + return lineTotal; + } + + public void setLineTotal(BigDecimal lineTotal) { + this.lineTotal = lineTotal; + } +} 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 new file mode 100644 index 00000000..4e2442f6 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java @@ -0,0 +1,43 @@ +package com.petshop.backend.dto.cart; + +import java.math.BigDecimal; +import java.util.List; + +public class CartResponse { + + private Long cartId; + private Long storeId; + private String cartStatus; + private List items; + private BigDecimal subtotalAmount; + private BigDecimal discountAmount; + private BigDecimal totalAmount; + private String couponCode; + + public CartResponse() { + } + + public Long getCartId() { return cartId; } + public void setCartId(Long cartId) { this.cartId = cartId; } + + public Long getStoreId() { return storeId; } + public void setStoreId(Long storeId) { this.storeId = storeId; } + + public String getCartStatus() { return cartStatus; } + public void setCartStatus(String cartStatus) { this.cartStatus = cartStatus; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public BigDecimal getSubtotalAmount() { return subtotalAmount; } + public void setSubtotalAmount(BigDecimal subtotalAmount) { this.subtotalAmount = subtotalAmount; } + + public BigDecimal getDiscountAmount() { return discountAmount; } + public void setDiscountAmount(BigDecimal discountAmount) { this.discountAmount = discountAmount; } + + public BigDecimal getTotalAmount() { return totalAmount; } + public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; } + + public String getCouponCode() { return couponCode; } + public void setCouponCode(String couponCode) { this.couponCode = couponCode; } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutRequest.java b/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutRequest.java new file mode 100644 index 00000000..084d4857 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutRequest.java @@ -0,0 +1,24 @@ +package com.petshop.backend.dto.cart; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class CheckoutRequest { + + @NotNull + private Long storeId; + + @NotBlank + private String paymentMethodId; + + public Long getStoreId() { return storeId; } + public void setStoreId(Long storeId) { this.storeId = storeId; } + + public String getPaymentMethodId() { + return paymentMethodId; + } + + public void setPaymentMethodId(String paymentMethodId) { + this.paymentMethodId = paymentMethodId; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutResponse.java b/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutResponse.java new file mode 100644 index 00000000..c703fbc0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CheckoutResponse.java @@ -0,0 +1,57 @@ +package com.petshop.backend.dto.cart; + +import java.math.BigDecimal; + +public class CheckoutResponse { + + private Long cartId; + private String clientSecret; + private BigDecimal totalAmount; + private String status; + + public CheckoutResponse() { + } + + public CheckoutResponse(Long cartId, String clientSecret, BigDecimal totalAmount, String status) { + this.cartId = cartId; + this.clientSecret = clientSecret; + this.totalAmount = totalAmount; + this.status = status; + } + + public Long getCartId() { + + return cartId; + } + + public void setCartId(Long cartId) { + this.cartId = cartId; + } + + public String getClientSecret() { + + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public BigDecimal getTotalAmount() { + + return totalAmount; + } + + public void setTotalAmount(BigDecimal totalAmount) { + this.totalAmount = totalAmount; + } + + public String getStatus() { + + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/UpdateCartItemRequest.java b/backend/src/main/java/com/petshop/backend/dto/cart/UpdateCartItemRequest.java new file mode 100644 index 00000000..205aaa4a --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/UpdateCartItemRequest.java @@ -0,0 +1,32 @@ +package com.petshop.backend.dto.cart; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class UpdateCartItemRequest { + + @NotNull + private Long cartItemId; + + @NotNull + @Min(1) + private Integer quantity; + + public Long getCartItemId() { + + return cartItemId; + } + + public void setCartItemId(Long cartItemId) { + this.cartItemId = cartItemId; + } + + public Integer getQuantity() { + + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/petshop/backend/repository/CartItemRepository.java b/backend/src/main/java/com/petshop/backend/repository/CartItemRepository.java index ffcdc166..f610233c 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CartItemRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CartItemRepository.java @@ -5,9 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface CartItemRepository extends JpaRepository { List findByCartCartId(Long cartId); + + Optional findByCartCartIdAndProductProdId(Long cartId, Long prodId); + + void deleteByCartCartId(Long cartId); } diff --git a/backend/src/main/java/com/petshop/backend/repository/CartRepository.java b/backend/src/main/java/com/petshop/backend/repository/CartRepository.java index 3f6a974a..76ca1b5b 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CartRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CartRepository.java @@ -2,6 +2,8 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Cart; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -13,4 +15,9 @@ public interface CartRepository extends JpaRepository { List findByUserId(Long userId); Optional findByUserIdAndCartStatus(Long userId, String cartStatus); + + @Query("SELECT c FROM Cart c WHERE c.user.id = :userId AND c.store.storeId = :storeId AND c.cartStatus = :status") + Optional findActiveCartByUserAndStore(@Param("userId") Long userId, + @Param("storeId") Long storeId, + @Param("status") String status); } diff --git a/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java b/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java index 64870204..abfc3431 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java @@ -10,4 +10,6 @@ import java.util.Optional; public interface CouponRepository extends JpaRepository { Optional findByCouponCode(String couponCode); + + Optional findByCouponCodeIgnoreCase(String couponCode); } diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java new file mode 100644 index 00000000..97819bee --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -0,0 +1,301 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.cart.*; +import com.petshop.backend.entity.*; +import com.petshop.backend.exception.BusinessException; +import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.*; +import com.stripe.Stripe; +import com.stripe.exception.StripeException; +import com.stripe.model.PaymentIntent; +import com.stripe.param.PaymentIntentCreateParams; +import jakarta.annotation.PostConstruct; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class CartService { + + private final CartRepository cartRepository; + private final CartItemRepository cartItemRepository; + private final UserRepository userRepository; + private final StoreRepository storeRepository; + private final ProductRepository productRepository; + private final CouponRepository couponRepository; + + @Value("${stripe.secret-key:}") + private String stripeSecretKey; + + public CartService(CartRepository cartRepository, + CartItemRepository cartItemRepository, + UserRepository userRepository, + StoreRepository storeRepository, + ProductRepository productRepository, + CouponRepository couponRepository) { + this.cartRepository = cartRepository; + this.cartItemRepository = cartItemRepository; + this.userRepository = userRepository; + this.storeRepository = storeRepository; + this.productRepository = productRepository; + this.couponRepository = couponRepository; + } + + @PostConstruct + public void initStripe() { + if (stripeSecretKey != null && !stripeSecretKey.isBlank()) { + Stripe.apiKey = stripeSecretKey; + } + } + + public CartResponse getActiveCart(Long userId, Long storeId) { + return cartRepository + .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .map(this::toResponse) + .orElse(null); + } + + @Transactional + public CartResponse addItem(Long userId, AddToCartRequest request) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found")); + StoreLocation store = storeRepository.findById(request.getStoreId()).orElseThrow(() -> new ResourceNotFoundException("Store not found")); + Product product = productRepository.findById(request.getProdId()).orElseThrow(() -> new ResourceNotFoundException("Product not found")); + + if (request.getQuantity() < 1) { + throw new BusinessException("Quantity must be at least 1"); + } + + Cart cart = cartRepository + .findActiveCartByUserAndStore(userId, request.getStoreId(), "ACTIVE") + .orElseGet(() -> { + Cart newCart = new Cart(); + newCart.setUser(user); + newCart.setStore(store); + newCart.setCartStatus("ACTIVE"); + + return cartRepository.save(newCart); + }); + + cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId()) + .ifPresentOrElse( + existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()), + () -> { + CartItem item = new CartItem(); + item.setCart(cart); + item.setProduct(product); + item.setQuantity(request.getQuantity()); + item.setUnitPrice(product.getProdPrice()); + cartItemRepository.save(item); + } + ); + + recalculate(cart); + + return toResponse(cart); + } + + @Transactional + public CartResponse updateItem(Long userId, UpdateCartItemRequest request) { + CartItem item = cartItemRepository.findById(request.getCartItemId()) + .orElseThrow(() -> new ResourceNotFoundException("Cart item not found")); + + if (!item.getCart().getUser().getId().equals(userId)) { + throw new BusinessException("Access denied"); + } + + if (!item.getCart().getCartStatus().equals("ACTIVE")) { + throw new BusinessException("Cart is not active"); + } + + if (request.getQuantity() < 1) { + throw new BusinessException("Quantity must be at least 1"); + } + + item.setQuantity(request.getQuantity()); + cartItemRepository.save(item); + recalculate(item.getCart()); + + return toResponse(item.getCart()); + } + + @Transactional + public CartResponse removeItem(Long userId, Long cartItemId) { + CartItem item = cartItemRepository.findById(cartItemId) + .orElseThrow(() -> new ResourceNotFoundException("Cart item not found")); + + if (!item.getCart().getUser().getId().equals(userId)) { + throw new BusinessException("Access denied"); + } + + if (!item.getCart().getCartStatus().equals("ACTIVE")) { + throw new BusinessException("Cart is not active"); + } + + Cart cart = item.getCart(); + cartItemRepository.delete(item); + recalculate(cart); + + return toResponse(cart); + } + + @Transactional + public void clearCart(Long userId, Long storeId) { + cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .ifPresent(cart -> { + cartItemRepository.deleteByCartCartId(cart.getCartId()); + cart.setSubtotalAmount(BigDecimal.ZERO); + cart.setDiscountAmount(BigDecimal.ZERO); + cart.setTotalAmount(BigDecimal.ZERO); + cart.setCoupon(null); + cartRepository.save(cart); + }); + } + + @Transactional + public CartResponse applyCoupon(Long userId, Long storeId, String couponCode) { + Cart cart = cartRepository + .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .orElseThrow(() -> new BusinessException("No active cart found")); + + Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode) + .orElseThrow(() -> new BusinessException("Invalid coupon code")); + + LocalDateTime now = LocalDateTime.now(); + + if (!coupon.getActive()) { + throw new BusinessException("Coupon is no longer active"); + } + + if (coupon.getStartsAt() != null && now.isBefore(coupon.getStartsAt())) { + throw new BusinessException("Coupon is not yet valid"); + } + + if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) { + throw new BusinessException("Coupon has expired"); + } + + if (coupon.getMinOrderAmount() != null && cart.getSubtotalAmount().compareTo(coupon.getMinOrderAmount()) < 0) { + throw new BusinessException("Minimum order amount of $" + coupon.getMinOrderAmount() + " required"); + } + + cart.setCoupon(coupon); + recalculate(cart); + + return toResponse(cart); + } + + @Transactional + public CheckoutResponse checkout(Long userId, Long storeId, String paymentMethodId) { + Cart cart = cartRepository + .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .orElseThrow(() -> new BusinessException("No active cart found")); + + List items = cartItemRepository.findByCartCartId(cart.getCartId()); + if (items.isEmpty()) { + throw new BusinessException("Cart is empty"); + } + + long amountInCents = cart.getTotalAmount() + .multiply(BigDecimal.valueOf(100)) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + if (amountInCents < 50) { + throw new BusinessException("Order total is too low to process payment"); + } + + try { + PaymentIntentCreateParams params = PaymentIntentCreateParams.builder() + .setAmount(amountInCents) + .setCurrency("usd") + .setPaymentMethod(paymentMethodId) + .setConfirm(true) + .setReturnUrl("http://localhost:3000/cart/confirmation") + .putMetadata("cartId", String.valueOf(cart.getCartId())) + .putMetadata("userId", String.valueOf(userId)) + .build(); + + PaymentIntent intent = PaymentIntent.create(params); + + if ("succeeded".equals(intent.getStatus()) + || "requires_action".equals(intent.getStatus())) { + cart.setCartStatus("CHECKED_OUT"); + cartRepository.save(cart); + } + + return new CheckoutResponse( + cart.getCartId(), + intent.getClientSecret(), + cart.getTotalAmount(), + intent.getStatus() + ); + + } + + catch (StripeException e) { + throw new BusinessException("Payment processing failed: " + e.getMessage()); + } + } + + private void recalculate(Cart cart) { + List items = cartItemRepository.findByCartCartId(cart.getCartId()); + + BigDecimal subtotal = items.stream() + .map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + cart.setSubtotalAmount(subtotal); + + BigDecimal discount = BigDecimal.ZERO; + Coupon coupon = cart.getCoupon(); + + if (coupon != null) { + if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { + discount = subtotal.multiply(coupon.getDiscountValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { + discount = coupon.getDiscountValue().min(subtotal); + } + } + + discount = discount.max(BigDecimal.ZERO).min(subtotal); + cart.setDiscountAmount(discount); + cart.setTotalAmount(subtotal.subtract(discount).max(BigDecimal.ZERO)); + cartRepository.save(cart); + } + + private CartResponse toResponse(Cart cart) { + List itemResponses = cartItemRepository + .findByCartCartId(cart.getCartId()) + .stream() + .map(item -> new CartItemResponse( + item.getCartItemId(), + item.getProduct().getProdId(), + item.getProduct().getProdName(), + item.getProduct().getImageUrl(), + item.getUnitPrice(), + item.getQuantity(), + item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())) + )) + .toList(); + + CartResponse response = new CartResponse(); + response.setCartId(cart.getCartId()); + response.setStoreId(cart.getStore() != null ? cart.getStore().getStoreId() : null); + response.setCartStatus(cart.getCartStatus()); + response.setItems(itemResponses); + response.setSubtotalAmount(cart.getSubtotalAmount()); + response.setDiscountAmount(cart.getDiscountAmount()); + response.setTotalAmount(cart.getTotalAmount()); + response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null); + + return response; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 07b2c86b..425bef32 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -50,6 +50,9 @@ jwt: secret: ${JWT_SECRET:change_me_please_make_this_at_least_32_characters_long_for_security} expiration: ${JWT_EXPIRATION:86400000} +stripe: + secret-key: ${STRIPE_SECRET_KEY:} + logging: level: com.petshop: ${LOG_LEVEL:INFO} diff --git a/web/app/cart/page.js b/web/app/cart/page.js new file mode 100644 index 00000000..bbf4b94d --- /dev/null +++ b/web/app/cart/page.js @@ -0,0 +1,356 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; +import { useCart } from "@/context/CartContext"; +import { loadStripe } from "@stripe/stripe-js"; +import { + Elements, + PaymentElement, + useStripe, + useElements, +} from "@stripe/react-stripe-js"; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ""); + +function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) { + const stripe = useStripe(); + const elements = useElements(); + const [paying, setPaying] = useState(false); + const [payError, setPayError] = useState(null); + + async function handlePay(e) { + e.preventDefault(); + if (!stripe || !elements) return; + setPaying(true); + setPayError(null); + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { return_url: window.location.origin + "/cart/confirmation" }, + redirect: "if_required", + }); + + if (error) { + setPayError(error.message); + setPaying(false); + } + + else { + onSuccess(); + } + } + + return ( +
+

Payment Details

+

+ Total to pay: ${parseFloat(totalAmount).toFixed(2)} +

+ + {payError &&

{payError}

} +
+ + +
+
+ ); +} + +export default function CartPage() { + const { user, loading: authLoading } = useAuth(); + const { + cart, + cartLoading, + cartError, + selectedStoreId, + updateItem, + removeItem, + clearCart, + applyCoupon, + checkout, + } = useCart(); + const router = useRouter(); + + const [couponInput, setCouponInput] = useState(""); + const [couponError, setCouponError] = useState(null); + const [couponLoading, setCouponLoading] = useState(false); + + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutError, setCheckoutError] = useState(null); + const [clientSecret, setClientSecret] = useState(null); + const [checkoutTotal, setCheckoutTotal] = useState(null); + const [confirmed, setConfirmed] = useState(false); + + const [localQuantities, setLocalQuantities] = useState({}); + + useEffect(() => { + if (!authLoading && !user) { + router.push("/login"); + } + }, [authLoading, user, router]); + + useEffect(() => { + if (cart?.items) { + const map = {}; + cart.items.forEach((i) => (map[i.cartItemId] = i.quantity)); + setLocalQuantities(map); + } + }, [cart]); + + async function handleQuantityChange(cartItemId, newQty) { + if (newQty < 1) { + return; + } + + setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty })); + try { + await updateItem(cartItemId, newQty); + } + + catch { + if (cart?.items) { + const original = cart.items.find((i) => i.cartItemId === cartItemId); + if (original) { + setLocalQuantities((prev) => ({ ...prev, [cartItemId]: original.quantity })); + } + } + } + } + + async function handleRemove(cartItemId) { + try { + await removeItem(cartItemId); + } + + catch { + } + } + + async function handleApplyCoupon() { + if (!couponInput.trim()) return; + setCouponLoading(true); + setCouponError(null); + try { + await applyCoupon(couponInput.trim()); + setCouponInput(""); + } + + catch (err) { + setCouponError(err.message); + } + + finally { + setCouponLoading(false); + } + } + + async function handleCheckout() { + if (!cart?.items?.length) return; + setCheckoutLoading(true); + setCheckoutError(null); + try { + const result = await checkout("pm_card_visa"); + if (result?.clientSecret) { + setClientSecret(result.clientSecret); + setCheckoutTotal(result.totalAmount); + } + + else if (result?.status === "succeeded") { + setConfirmed(true); + } + } + + catch (err) { + setCheckoutError(err.message); + } + + finally { + setCheckoutLoading(false); + } + } + + if (authLoading || cartLoading) { + return

Loading…

; + } + + if (!user) return null; + + if (confirmed) { + return ( +
+
+
+

Order Confirmed!

+

+ Thank you for your purchase. Your order has been placed successfully. +

+ +
+
+ ); + } + + if (!selectedStoreId) { + return ( +
+

Please select a store from the navigation bar to view your cart.

+
+ ); + } + + const items = cart?.items ?? []; + + return ( +
+

Your Cart

+ + {cartError &&

{cartError}

} + + {items.length === 0 && !cartError && ( +
+

Your cart is empty.

+ +
+ )} + + {items.length > 0 && ( +
+
+ {items.map((item) => ( +
+ {item.prodName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+

{item.prodName}

+

${parseFloat(item.unitPrice).toFixed(2)} each

+
+
+ + {localQuantities[item.cartItemId] ?? item.quantity} + +
+

+ ${(parseFloat(item.unitPrice) * (localQuantities[item.cartItemId] ?? item.quantity)).toFixed(2)} +

+ +
+ ))} + + +
+ + +
+ )} +
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index 43ec07e6..7840234a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1843,3 +1843,527 @@ body { border-color: #999; color: #333; } + +/* ── Store Selector ──────────────────────────────────────── */ + +.nav-store-select { + background: rgba(255, 255, 255, 0.15); + color: white; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 6px; + padding: 0.3rem 0.6rem; + font-size: 0.9rem; + cursor: pointer; + margin-right: 0.5rem; + outline: none; + transition: background 0.2s ease; +} + +.nav-store-select option { + background: #333; + color: white; +} + +.nav-store-select:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* ── Cart Badge ──────────────────────────────────────────── */ + +.nav-cart-btn { + position: relative; + display: inline-flex; + align-items: center; + font-size: 1.4rem; + text-decoration: none; + margin-right: 0.5rem; + padding: 0.2rem 0.4rem; + border-radius: 6px; + transition: background 0.2s ease; +} + +.nav-cart-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.nav-cart-badge { + position: absolute; + top: -4px; + right: -6px; + background: #e53935; + color: white; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; +} + +/* ProductCard Add-to-Cart */ + +.product-card-wrapper { + display: flex; + flex-direction: column; + text-decoration: none; +} + +.product-card-link { + text-decoration: none; + color: inherit; + flex: 1; +} + +.product-card-actions { + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.product-card-qty-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.product-card-qty-btn { + width: 28px; + height: 28px; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s ease; +} + +.product-card-qty-btn:hover:not(:disabled) { + border-color: orange; +} + +.product-card-qty-val { + min-width: 24px; + text-align: center; + font-weight: 600; + font-size: 0.9rem; +} + +.product-card-add-btn { + width: 100%; + padding: 0.45rem; + background: orange; + color: white; + border: none; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.product-card-add-btn:hover:not(:disabled) { + background: #e69500; +} + +.product-card-add-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.product-card-feedback { + font-size: 0.78rem; + color: #2e7d32; + margin: 0; + text-align: center; +} + +/* Cart Page */ + +.cart-page { + max-width: 1100px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +.cart-title { + font-size: 2rem; + font-weight: 800; + color: #222; + margin-bottom: 1.5rem; +} + +.cart-status-msg { + text-align: center; + color: #888; + margin-top: 3rem; + font-size: 1rem; +} + +.cart-error-msg { + color: #c0392b; + font-size: 0.9rem; + margin: 0.5rem 0; +} + +.cart-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 4rem; +} + +.cart-empty-msg { + font-size: 1.1rem; + color: #666; +} + +.cart-continue-btn { + background: orange; + color: white; + border: none; + border-radius: 8px; + padding: 0.65rem 1.5rem; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.cart-continue-btn:hover { + background: #e69500; +} + +.cart-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 2rem; + align-items: start; +} + +@media (max-width: 800px) { + .cart-layout { + grid-template-columns: 1fr; + } +} + +.cart-items-section { + display: flex; + flex-direction: column; + gap: 0; +} + +.cart-item-row { + display: grid; + grid-template-columns: 72px 1fr auto auto auto; + align-items: center; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid #eee; +} + +.cart-item-img { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + background: #f5f5f5; +} + +.cart-item-details { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.cart-item-name { + font-weight: 700; + font-size: 0.95rem; + color: #222; + margin: 0; +} + +.cart-item-unit-price { + font-size: 0.82rem; + color: #888; + margin: 0; +} + +.cart-item-qty-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.cart-qty-btn { + width: 28px; + height: 28px; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s ease; +} + +.cart-qty-btn:hover { + border-color: orange; +} + +.cart-qty-val { + min-width: 28px; + text-align: center; + font-weight: 600; +} + +.cart-item-line-total { + font-weight: 700; + font-size: 0.95rem; + color: #222; + min-width: 60px; + text-align: right; + margin: 0; +} + +.cart-item-remove-btn { + background: none; + border: none; + color: #bbb; + font-size: 1rem; + cursor: pointer; + padding: 0.2rem; + transition: color 0.2s ease; +} + +.cart-item-remove-btn:hover { + color: #c0392b; +} + +.cart-clear-btn { + align-self: flex-start; + margin-top: 1rem; + background: none; + border: 1px solid #ddd; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font-size: 0.82rem; + color: #888; + cursor: pointer; + transition: all 0.2s ease; +} + +.cart-clear-btn:hover { + border-color: #c0392b; + color: #c0392b; +} + +.cart-summary { + background: #fafafa; + border: 1px solid #eee; + border-radius: 14px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + position: sticky; + top: 84px; +} + +.cart-summary-title { + font-size: 1.2rem; + font-weight: 800; + color: #222; + margin: 0 0 0.5rem; +} + +.cart-summary-row { + display: flex; + justify-content: space-between; + font-size: 0.95rem; + color: #444; +} + +.cart-summary-discount { + color: #2e7d32; +} + +.cart-summary-total { + font-size: 1.1rem; + font-weight: 800; + color: #222; + border-top: 1px solid #eee; + padding-top: 0.75rem; + margin-top: 0.25rem; +} + +.cart-coupon-section { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.cart-coupon-input { + flex: 1; + min-width: 0; + border: 1px solid #ddd; + border-radius: 7px; + padding: 0.5rem 0.75rem; + font-size: 0.88rem; + outline: none; + transition: border-color 0.2s ease; +} + +.cart-coupon-input:focus { + border-color: orange; +} + +.cart-coupon-btn { + background: #333; + color: white; + border: none; + border-radius: 7px; + padding: 0.5rem 0.9rem; + font-size: 0.88rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.cart-coupon-btn:hover:not(:disabled) { + background: #111; +} + +.cart-coupon-error { + width: 100%; + font-size: 0.8rem; + color: #c0392b; + margin: 0; +} + +.cart-checkout-btn { + width: 100%; + padding: 0.85rem; + background: orange; + color: white; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 800; + cursor: pointer; + margin-top: 0.5rem; + transition: background 0.2s ease; +} + +.cart-checkout-btn:hover:not(:disabled) { + background: #e69500; +} + +.cart-checkout-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cart-payment-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.cart-payment-title { + font-size: 1rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.cart-payment-total { + font-size: 0.9rem; + color: #444; + margin: 0; +} + +.cart-payment-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cart-pay-btn { + width: 100%; + padding: 0.75rem; + background: #1a56db; + color: white; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.cart-pay-btn:hover:not(:disabled) { + background: #1446c0; +} + +.cart-pay-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cart-cancel-btn { + width: 100%; + padding: 0.65rem; + background: white; + color: #666; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.cart-cancel-btn:hover { + border-color: #999; + color: #333; +} + +.cart-confirmation { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 5rem; + text-align: center; +} + +.cart-confirmation-icon { + font-size: 3.5rem; +} + +.cart-confirmation-title { + font-size: 1.8rem; + font-weight: 800; + color: #222; + margin: 0; +} + +.cart-confirmation-body { + font-size: 1rem; + color: #555; + max-width: 400px; + margin: 0; +} diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js index 64e8157a..58cea326 100644 --- a/web/components/ClientProviders.js +++ b/web/components/ClientProviders.js @@ -1,7 +1,12 @@ "use client"; import { AuthProvider } from "@/context/AuthContext"; +import { CartProvider } from "@/context/CartContext"; -export default function ClientProviders({children}) { - return {children}; +export default function ClientProviders({ children }) { + return ( + + {children} + + ); } diff --git a/web/components/Navigation.js b/web/components/Navigation.js index 3259c1d1..64f50e3a 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -3,11 +3,25 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; import { useAuth } from "@/context/AuthContext"; +import { useCart } from "@/context/CartContext"; export default function DisplayNav() { - const {user, logout, loading} = useAuth(); + const { user, token, logout, loading } = useAuth(); + const { itemCount, selectedStoreId, setStoreId } = useCart(); const router = useRouter(); + const [stores, setStores] = useState([]); + + useEffect(() => { + if (!token) return; + fetch("/api/v1/stores?size=100", { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data) setStores(data.content ?? []); }) + .catch(() => {}); + }, [token]); function handleLogout() { logout(); @@ -16,12 +30,14 @@ export default function DisplayNav() { return (