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 49281666..6887326c 100644 --- a/backend/src/main/java/com/petshop/backend/controller/CartController.java +++ b/backend/src/main/java/com/petshop/backend/controller/CartController.java @@ -91,4 +91,13 @@ public class CartController { return ResponseEntity.noContent().build(); } + + @PostMapping("/checkout/cancel") + @PreAuthorize("isAuthenticated()") + public ResponseEntity cancelCheckout(@RequestParam Long storeId) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + cartService.cancelCheckout(userId, storeId); + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index 5c7d87de..e9d8459a 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -68,9 +68,10 @@ public class ChatController { @GetMapping("/conversations") @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") - public ResponseEntity> getConversations() { + public ResponseEntity> getConversations( + @RequestParam(required = false, defaultValue = "false") boolean mine) { User user = getCurrentUser(); - List conversations = chatService.getConversations(user.getId(), user.getRole()); + List conversations = chatService.getConversations(user.getId(), user.getRole(), mine); return ResponseEntity.ok(conversations); } 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 index d5fc9faf..3e0c2919 100644 --- a/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java @@ -16,6 +16,7 @@ public class CartResponse { private String couponCode; private Boolean pointsApplied; private Integer availableLoyaltyPoints; + private Boolean checkoutPending; public CartResponse() { } @@ -53,4 +54,7 @@ public class CartResponse { public Integer getAvailableLoyaltyPoints() { return availableLoyaltyPoints; } public void setAvailableLoyaltyPoints(Integer availableLoyaltyPoints) { this.availableLoyaltyPoints = availableLoyaltyPoints; } + public Boolean getCheckoutPending() { return checkoutPending; } + public void setCheckoutPending(Boolean checkoutPending) { this.checkoutPending = checkoutPending; } + } 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 1714f518..76543163 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -482,15 +482,21 @@ public class CartService { 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())) - )) + .map(item -> { + String rawImageUrl = item.getProduct().getImageUrl(); + String imageUrl = (rawImageUrl != null && !rawImageUrl.isBlank()) + ? "/api/v1/products/" + item.getProduct().getProdId() + "/image" + : null; + return new CartItemResponse( + item.getCartItemId(), + item.getProduct().getProdId(), + item.getProduct().getProdName(), + imageUrl, + item.getUnitPrice(), + item.getQuantity(), + item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())) + ); + }) .toList(); CartResponse response = new CartResponse(); @@ -505,7 +511,23 @@ public class CartService { response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null); response.setPointsApplied(cart.getPointsApplied()); response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null); - + response.setCheckoutPending(cart.getCheckoutPending()); + return response; } + + @Transactional + public void cancelCheckout(Long userId, Long storeId) { + cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .ifPresent(cart -> { + if (!Boolean.TRUE.equals(cart.getCheckoutPending())) { + return; + } + cart.setCheckoutPending(false); + cart.setCheckoutAmount(null); + cart.setCheckoutStartedAt(null); + cart.setCheckoutPaymentIntentId(null); + cartRepository.save(cart); + }); + } } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 5b347cf5..bee48228 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -65,10 +65,10 @@ public class ChatService { return ConversationResponse.fromEntity(conversation, request.getMessage(), userId); } - public List getConversations(Long userId, User.Role role) { + public List getConversations(Long userId, User.Role role, boolean mine) { List conversations; - if (role == User.Role.CUSTOMER) { + if (mine || role == User.Role.CUSTOMER) { conversations = conversationRepository.findByCustomerId(userId); } else if (role == User.Role.STAFF) { List assignedToMe = conversationRepository.findByStaffId(userId); diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 2d4e7a18..3f364f94 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -15,7 +15,6 @@ export default function AdoptPage() { const [pets, setPets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [health, setHealth] = useState(null); const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); @@ -26,16 +25,9 @@ export default function AdoptPage() { // Species options come from a dedicated fetch (only store-filtered, no species filter) const [speciesOptions, setSpeciesOptions] = useState([]); - // ---------- health check ---------- - useEffect(() => { - fetch(`${API_BASE}/api/v1/health`) - .then((res) => (res.ok ? setHealth("online") : setHealth("error"))) - .catch(() => setHealth("offline")); - }, []); - useEffect(() => { setSelectedSpecies(""); - const params = new URLSearchParams({ status: "Available", page: "0", size: String(PAGE_SIZE) }); + const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) }); if (selectedStoreId) params.set("storeId", String(selectedStoreId)); fetch(`${API_BASE}/api/v1/pets?${params}`) @@ -62,7 +54,6 @@ export default function AdoptPage() { page: String(page), size: String(PAGE_SIZE), sort: "id,asc", - status: "Available", }); if (query) params.set("q", query); if (selectedSpecies) params.set("species", selectedSpecies); @@ -161,15 +152,6 @@ export default function AdoptPage() { Clear Filters )} - - @@ -180,13 +162,7 @@ export default function AdoptPage() {

Failed to load pets

{error} -

- {health === "offline" - ? "The Spring Boot backend is not reachable. Make sure it is running in IntelliJ on port 8080." - : health === "error" - ? "The backend responded with an error. Check the IntelliJ Run console for stack traces." - : "The backend is reachable but the /pets endpoint failed. Check the IntelliJ Run console."} -

+

Make sure the backend is running and try again.

)} diff --git a/web/app/cart/page.js b/web/app/cart/page.js index 5be185a8..e555a5b2 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -85,6 +85,7 @@ export default function CartPage() { clearCart, applyCoupon, checkout, + cancelCheckout, } = useCart(); const router = useRouter(); @@ -114,6 +115,14 @@ export default function CartPage() { } }, [cart]); + // If the cart arrives already locked (e.g. user closed the page mid-checkout) + // and there is no active Stripe session, release the lock automatically. + useEffect(() => { + if (cart?.checkoutPending && !clientSecret) { + cancelCheckout().catch(() => {}); + } + }, [cart?.checkoutPending, clientSecret, cancelCheckout]); + async function handleQuantityChange(cartItemId, newQty) { if (newQty < 1) { return; @@ -351,7 +360,10 @@ export default function CartPage() { setClientSecret(null); setConfirmed(true); }} - onCancel={() => setClientSecret(null)} + onCancel={async () => { + await cancelCheckout().catch(() => {}); + setClientSecret(null); + }} /> )} diff --git a/web/app/globals.css b/web/app/globals.css index c468269c..dd607b01 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -30,13 +30,14 @@ body { top: 0; left: 0; width: 100%; - background: orange; + background: #e68672; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); z-index: 1000; padding: 0.5rem 2rem; display: flex; align-items: center; - height: 70px; + flex-wrap: wrap; + min-height: 70px; border-radius: 0px 0px 10px 10px; } @@ -62,14 +63,18 @@ body { display: flex; align-items: center; gap: 2rem; - margin-left: 2rem; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); } /* Indivdual Link Styles */ .nav-link { - color: rgb(255, 255, 255); + color: #2f2f2f; text-decoration: none; font-size: 1.1rem; + font-weight: 700; padding: 0.5rem 1rem; border-radius: 4px; transition: all 0.3s ease; @@ -365,7 +370,7 @@ body { display: flex; align-items: center; justify-content: center; - height: 160px; + aspect-ratio: 1 / 1; } .pet-card-emoji { @@ -380,31 +385,34 @@ body { } .pet-card-body { - padding: 1rem 1.25rem 1.25rem; + padding: 0.6rem 0.75rem 0.75rem; display: flex; flex-direction: column; - gap: 0.3rem; + gap: 0.2rem; } .pet-card-name { - font-size: 1.2rem; + font-size: 0.95rem; font-weight: 700; color: #222; margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .pet-card-species { - font-size: 0.95rem; + font-size: 0.8rem; color: #666; margin: 0; } .pet-card-status { display: inline-block; - margin-top: 0.4rem; - padding: 0.2rem 0.75rem; + margin-top: 0.2rem; + padding: 0.15rem 0.5rem; border-radius: 20px; - font-size: 0.8rem; + font-size: 0.7rem; font-weight: 600; text-transform: capitalize; width: fit-content; @@ -595,6 +603,71 @@ body { margin: 0 0 1rem; } +.product-qty-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.product-qty-label { + font-size: 0.95rem; + font-weight: 600; + color: #555; +} + +.product-qty-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.product-add-to-cart-btn { + width: 100%; + padding: 0.85rem; + background: #e68672; + color: #2f2f2f; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} + +.product-add-to-cart-btn:hover:not(:disabled) { + background: #d4705e; + color: #fff; +} + +.product-add-to-cart-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.product-add-to-cart-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.product-cart-feedback { + margin: 0.75rem 0 0; + font-size: 0.9rem; + border-radius: 8px; + padding: 0.6rem 1rem; +} + +.product-cart-feedback--success { + background: #f0fff4; + border: 1px solid #b2dfdb; + color: #1a7a3c; +} + +.product-cart-feedback--error { + background: #fff0f0; + border: 1px solid #f5c6c6; + color: #c0392b; +} + .pet-detail-cta-btn { display: inline-block; padding: 0.65rem 1.5rem; @@ -733,8 +806,8 @@ body { } .adopt-grid { - grid-template-columns: repeat(2, 1fr); - gap: 1.25rem; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; } .pet-detail-card { @@ -754,7 +827,8 @@ body { @media (max-width: 480px) { .adopt-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(3, 1fr); + gap: 0.6rem; } .adopt-hero-title, @@ -884,29 +958,6 @@ body { gap: 1rem; } -.backend-status { - display: inline-block; - width: 12px; - height: 12px; - border-radius: 50%; - flex-shrink: 0; -} - -.backend-status--online { - background: #1a7a3c; -} - -.backend-status--offline { - background: #c0392b; -} - -.backend-status--error { - background: #e67e00; -} - -.backend-status--checking { - background: #bbb; -} .adopt-error-box { max-width: 640px; @@ -959,26 +1010,26 @@ body { } .nav-greeting { - font-weight: 600; + font-weight: 700; white-space: nowrap; } .nav-register-btn { - background: white; - color: orange !important; + background: #2f2f2f; + color: #e68672 !important; font-weight: 600; border-radius: 20px; padding: 0.4rem 1rem !important; } .nav-register-btn:hover { - background: #fff3e0 !important; + background: #444 !important; } .nav-logout-btn { - background: rgba(255, 255, 255, 0.2); - color: white; - border: 1px solid rgba(255, 255, 255, 0.6); + background: rgba(47, 47, 47, 0.15); + color: #2f2f2f; + border: 1px solid rgba(47, 47, 47, 0.4); border-radius: 20px; padding: 0.35rem 1rem; font-size: 0.95rem; @@ -988,7 +1039,7 @@ body { } .nav-logout-btn:hover { - background: rgba(255, 255, 255, 0.35); + background: rgba(47, 47, 47, 0.25); } /* Login/Register */ @@ -1896,12 +1947,12 @@ body { color: #333; } -/* ── Store Selector ──────────────────────────────────────── */ +/* Store Selector */ .nav-store-select { - background: rgba(255, 255, 255, 0.15); - color: white; - border: 1px solid rgba(255, 255, 255, 0.4); + background: rgba(47, 47, 47, 0.1); + color: #2f2f2f; + border: 1px solid rgba(47, 47, 47, 0.35); border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.9rem; @@ -1912,15 +1963,15 @@ body { } .nav-store-select option { - background: #333; - color: white; + background: #fff; + color: #2f2f2f; } .nav-store-select:hover { - background: rgba(255, 255, 255, 0.25); + background: rgba(47, 47, 47, 0.2); } -/* ── Cart Badge ──────────────────────────────────────────── */ +/* Cart Badge */ .nav-cart-btn { position: relative; @@ -2422,3 +2473,365 @@ body { @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } } @keyframes spin { to { transform: rotate(360deg); } } + +/* Floating chat widget */ +.fc-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #aaa; + animation: bounce 1s infinite; +} + +/* Footer */ +.site-footer { + background: #e68672; + color: #2f2f2f; + margin-top: 4rem; + border-radius: 10px 10px 0 0; +} + +.footer-container { + max-width: 1200px; + margin: 0 auto; + padding: 3rem 2rem 2rem; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; +} + +.footer-logo { + border-radius: 50%; + margin-bottom: 0.75rem; +} + +.footer-tagline { + font-size: 0.95rem; + line-height: 1.5; + opacity: 0.9; + max-width: 260px; +} + +.footer-heading { + font-size: 1rem; + font-weight: 700; + margin-bottom: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.footer-links { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.footer-links a { + color: #2f2f2f; + text-decoration: none; + font-size: 0.95rem; + opacity: 0.85; + transition: opacity 0.2s ease; +} + +.footer-links a:hover { + opacity: 1; + text-decoration: underline; +} + +.footer-contact li { + font-size: 0.95rem; + opacity: 0.85; +} + +.footer-bottom { + border-top: 1px solid rgba(47, 47, 47, 0.2); + text-align: center; + padding: 1rem 2rem; + font-size: 0.85rem; + opacity: 0.8; +} + +@media (max-width: 900px) { + .footer-container { + grid-template-columns: 1fr 1fr; + } + .footer-brand { + grid-column: 1 / -1; + } +} + +@media (max-width: 550px) { + .footer-container { + grid-template-columns: 1fr; + } +} + +/* Mobile / Responsive */ + +@media (max-width: 768px) { + .navbar { + padding: 0.5rem 1rem; + } +} + +/* Hamburger button – hidden on desktop */ +.nav-mobile-bar { + display: none; +} + +.nav-hamburger { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 36px; + height: 36px; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: background 0.2s ease; +} + +.nav-hamburger:hover { + background: rgba(47, 47, 47, 0.12); +} + +.nav-hamburger span { + display: block; + height: 2px; + width: 100%; + background: #2f2f2f; + border-radius: 2px; + transition: transform 0.25s ease, opacity 0.25s ease; + transform-origin: center; +} + +/* Animate bars to X when open */ +.nav-hamburger--open span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.nav-hamburger--open span:nth-child(2) { + opacity: 0; +} +.nav-hamburger--open span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* Mobile slide-down drawer */ +.nav-drawer { + display: none; +} + +@media (max-width: 768px) { + /* Show hamburger bar, hide desktop nav */ + .nav-mobile-bar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + } + + .nav-links, + .nav-auth { + display: none; + } + + /* Drawer panel */ + .nav-drawer { + display: flex; + flex-direction: column; + position: absolute; + top: 70px; + left: 0; + width: 100%; + background: #e68672; + padding: 1rem 1.5rem 1.5rem; + z-index: 999; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + border-radius: 0 0 10px 10px; + gap: 0.25rem; + } + + .nav-drawer-link { + display: block; + color: #2f2f2f; + text-decoration: none; + font-size: 1.05rem; + font-weight: 500; + padding: 0.65rem 0.5rem; + border-radius: 6px; + transition: background 0.2s ease; + } + + .nav-drawer-link:hover { + background: rgba(47, 47, 47, 0.1); + } + + .nav-drawer-link--register { + margin-top: 0.25rem; + background: #2f2f2f; + color: #e68672 !important; + font-weight: 700; + border-radius: 20px; + padding: 0.6rem 1rem; + text-align: center; + } + + .nav-drawer-link--register:hover { + background: #444 !important; + } + + .nav-drawer-divider { + height: 1px; + background: rgba(47, 47, 47, 0.2); + margin: 0.5rem 0; + } + + .nav-store-select--drawer { + width: 100%; + margin: 0 0 0.25rem; + } + + .nav-logout-btn--drawer { + width: 100%; + margin-top: 0.25rem; + border-radius: 8px; + padding: 0.65rem; + font-size: 1rem; + } +} + +/* Cart item row – stack on small screens */ +@media (max-width: 600px) { + .cart-item-row { + grid-template-columns: 56px 1fr; + grid-template-rows: auto auto; + gap: 0.5rem 0.75rem; + } + + .cart-item-img { + width: 56px; + height: 56px; + grid-row: 1 / 3; + } + + .cart-item-details { + grid-column: 2; + grid-row: 1; + } + + .cart-item-qty-controls { + grid-column: 2; + grid-row: 2; + } + + .cart-item-line-total { + display: none; + } + + .cart-item-remove { + position: absolute; + top: 0.75rem; + right: 0; + } + + .cart-item-row { + position: relative; + padding-right: 2rem; + } +} + +/* Adopt filters - stack vertically on mobile */ +@media (max-width: 600px) { + .adopt-filters-row { + flex-direction: column; + align-items: stretch; + } + + .adopt-filter-group { + min-width: 0; + width: 100%; + } + + .adopt-filter-select { + width: 100%; + } + + .adopt-search-form { + flex-direction: column; + align-items: stretch; + } + + .adopt-search-input { + max-width: 100%; + } + + .adopt-search-btn, + .adopt-clear-btn { + width: 100%; + } +} + +/* Appointments - fix adopt grid on narrow screens */ +@media (max-width: 480px) { + .appt-adopt-grid { + grid-template-columns: 1fr; + } + + .appt-modal { + margin: 1rem; + padding: 1.25rem; + } + + .appt-slots-grid { + gap: 0.4rem; + } + + .appt-slot-btn { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + } +} + +/* Auth pages - reduce padding on very small screens */ +@media (max-width: 480px) { + .auth-card { + padding: 1.5rem 1.25rem; + } + + .auth-title { + font-size: 1.4rem; + } +} + +/* Profile - tighten padding on mobile */ +@media (max-width: 480px) { + .profile-card { + padding: 1.5rem 1.25rem; + } + + .profile-pets-section { + padding: 1.25rem; + } + + .profile-pets-header { + flex-wrap: wrap; + gap: 0.5rem; + } +} + +/* General – prevent horizontal overflow on all pages */ +html, body { + overflow-x: hidden; +} + +img, video, iframe { + max-width: 100%; +} diff --git a/web/app/layout.js b/web/app/layout.js index 0a50c8db..d1466ee6 100644 --- a/web/app/layout.js +++ b/web/app/layout.js @@ -1,6 +1,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import DisplayNav from "@/components/Navigation"; +import Footer from "@/components/Footer"; import ClientProviders from "@/components/ClientProviders"; export const metadata = { @@ -15,6 +16,7 @@ export default function RootLayout({children}) { {children} +