From 4c3c11995a0b984de80f2bd67358ac45ca6a0f2c Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Tue, 14 Apr 2026 05:24:40 -0600 Subject: [PATCH 1/2] Cart fixes (backend), adjusted header, added footer, mobile formatting updates --- .claude/settings.local.json | 7 + .../backend/controller/CartController.java | 9 + .../backend/dto/cart/CartResponse.java | 4 + .../petshop/backend/service/CartService.java | 42 +- web/app/adopt/page.js | 28 +- web/app/cart/page.js | 14 +- web/app/globals.css | 508 ++++++++++++++++-- web/app/layout.js | 2 + web/app/products/[id]/page.js | 1 + web/components/Footer.js | 57 ++ web/components/Navigation.js | 107 +++- web/components/ProductProfile.js | 84 ++- web/context/CartContext.js | 11 + web/lib/cartApi.js | 9 + 14 files changed, 776 insertions(+), 107 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 web/components/Footer.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..739d260a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")" + ] + } +} 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/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/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..25de93db 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,12 +63,15 @@ 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; padding: 0.5rem 1rem; @@ -365,7 +369,7 @@ body { display: flex; align-items: center; justify-content: center; - height: 160px; + aspect-ratio: 1 / 1; } .pet-card-emoji { @@ -380,31 +384,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 +602,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 +805,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 +826,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 +957,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; @@ -964,21 +1014,21 @@ body { } .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 +1038,7 @@ body { } .nav-logout-btn:hover { - background: rgba(255, 255, 255, 0.35); + background: rgba(47, 47, 47, 0.25); } /* Login/Register */ @@ -1896,12 +1946,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 +1962,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 +2472,355 @@ body { @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } } @keyframes spin { to { transform: rotate(360deg); } } + +/* 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} +
diff --git a/web/app/products/[id]/page.js b/web/app/products/[id]/page.js index 47a3059b..fb0b731f 100644 --- a/web/app/products/[id]/page.js +++ b/web/app/products/[id]/page.js @@ -41,6 +41,7 @@ export default function ProductDetailPage() { {!loading && !error && product && ( +
+ +
+ Leon's Pet Store logo +

+ Your neighbourhood pet store! +

+
+ +
+

Quick Links

+
    +
  • Home
  • +
  • Adopt a Pet
  • +
  • Online Store
  • +
  • Schedule an Appointment
  • +
  • AI Assistant
  • +
+
+ +
+

Company

+
    +
  • About Us
  • +
  • Contact Us
  • +
+
+ +
+

Contact

+
    +
  • (403) 123-4567
  • +
  • support@leonspetstore.com
  • +
  • 123 Street Street, Calgary, Alberta, Canada
  • +
+
+ +
+ +
+

© {new Date().getFullYear()} Leon's Pet Store. All rights reserved.

+
+
+ ); +} diff --git a/web/components/Navigation.js b/web/components/Navigation.js index a6e04101..afd1ea50 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -12,6 +12,7 @@ export default function DisplayNav() { const { itemCount, selectedStoreId, setStoreId } = useCart(); const router = useRouter(); const [stores, setStores] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { if (!token) return; @@ -26,27 +27,35 @@ export default function DisplayNav() { function handleLogout() { logout(); router.push("/"); + setMenuOpen(false); + } + + function closeMenu() { + setMenuOpen(false); } return ( ); } diff --git a/web/components/ProductProfile.js b/web/components/ProductProfile.js index fda41420..3e397b14 100644 --- a/web/components/ProductProfile.js +++ b/web/components/ProductProfile.js @@ -1,6 +1,35 @@ -import Link from "next/link"; +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { useCart } from "@/context/CartContext"; + +export default function ProductProfile({ prodId, prodName, categoryName, prodDesc, prodPrice, imageUrl }) { + const { user } = useAuth(); + const { addItem, selectedStoreId } = useCart(); + + const [quantity, setQuantity] = useState(1); + const [adding, setAdding] = useState(false); + const [feedback, setFeedback] = useState(null); // { type: "success"|"error", message } + + function changeQty(delta) { + setQuantity((q) => Math.max(1, q + delta)); + } + + async function handleAddToCart() { + setAdding(true); + setFeedback(null); + try { + await addItem(prodId, quantity); + setFeedback({ type: "success", message: `${quantity} × ${prodName} added to cart!` }); + setQuantity(1); + } catch (err) { + setFeedback({ type: "error", message: err.message ?? "Failed to add to cart." }); + } finally { + setAdding(false); + } + } -export default function ProductProfile({ prodName, categoryName, prodDesc, prodPrice, imageUrl }) { return (
@@ -36,6 +65,57 @@ export default function ProductProfile({ prodName, categoryName, prodDesc, prodP {prodDesc ?? "—"}
+ +
+ {!user ? ( +

+ Log in to purchase this item. +

+ ) : !selectedStoreId ? ( +

+ Select a store from the navigation bar to add items to your cart. +

+ ) : ( + <> +
+ Quantity +
+ + {quantity} + +
+
+ + + + {feedback && ( +

+ {feedback.message} +

+ )} + + )} +
); diff --git a/web/context/CartContext.js b/web/context/CartContext.js index d3dfc331..777da6ba 100644 --- a/web/context/CartContext.js +++ b/web/context/CartContext.js @@ -10,6 +10,7 @@ import { apiClearCart, apiApplyCoupon, apiCheckout, + apiCancelCheckout, } from "@/lib/cartApi"; const CartContext = createContext(null); @@ -133,6 +134,15 @@ export function CartProvider({ children }) { [token, selectedStoreId] ); + const cancelCheckout = useCallback( + async () => { + if (!token || !selectedStoreId) return; + await apiCancelCheckout(token, selectedStoreId); + await refreshCart(); + }, + [token, selectedStoreId, refreshCart] + ); + const itemCount = cart?.items?.reduce((sum, i) => sum + i.quantity, 0) ?? 0; return ( @@ -150,6 +160,7 @@ export function CartProvider({ children }) { clearCart, applyCoupon, checkout, + cancelCheckout, refreshCart, }} > diff --git a/web/lib/cartApi.js b/web/lib/cartApi.js index b47049bc..b8d608ce 100644 --- a/web/lib/cartApi.js +++ b/web/lib/cartApi.js @@ -91,3 +91,12 @@ export async function apiCompleteCheckout(token, paymentIntentId) { return handleResponse(res); } + +export async function apiCancelCheckout(token, storeId) { + const res = await fetch(`${BASE}/checkout/cancel?storeId=${storeId}`, { + method: "POST", + headers: authHeaders(token), + }); + + return handleResponse(res); +} From 995088ece2023cd532308b3747a6a4644627aa5c Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Tue, 14 Apr 2026 05:59:33 -0600 Subject: [PATCH 2/2] Chat now present in the bottom right. --- .claude/settings.local.json | 3 +- .../backend/controller/ChatController.java | 5 +- .../petshop/backend/service/ChatService.java | 4 +- web/app/globals.css | 13 +- web/components/ClientProviders.js | 9 +- web/components/FloatingChat.js | 464 ++++++++++++++++++ web/context/ChatWidgetContext.js | 166 +++++++ 7 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 web/components/FloatingChat.js create mode 100644 web/context/ChatWidgetContext.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 739d260a..253cb04b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")" + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", + "Bash(grep -E \"\\\\.js$|^d\")" ] } } 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/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/globals.css b/web/app/globals.css index 25de93db..dd607b01 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -74,6 +74,7 @@ body { color: #2f2f2f; text-decoration: none; font-size: 1.1rem; + font-weight: 700; padding: 0.5rem 1rem; border-radius: 4px; transition: all 0.3s ease; @@ -1009,7 +1010,7 @@ body { } .nav-greeting { - font-weight: 600; + font-weight: 700; white-space: nowrap; } @@ -2473,6 +2474,16 @@ 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; diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js index 58cea326..a5c6f9f3 100644 --- a/web/components/ClientProviders.js +++ b/web/components/ClientProviders.js @@ -2,11 +2,18 @@ import { AuthProvider } from "@/context/AuthContext"; import { CartProvider } from "@/context/CartContext"; +import { ChatWidgetProvider } from "@/context/ChatWidgetContext"; +import FloatingChat from "@/components/FloatingChat"; export default function ClientProviders({ children }) { return ( - {children} + + + {children} + + + ); } diff --git a/web/components/FloatingChat.js b/web/components/FloatingChat.js new file mode 100644 index 00000000..4b17232d --- /dev/null +++ b/web/components/FloatingChat.js @@ -0,0 +1,464 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; +import { useChatWidget } from "@/context/ChatWidgetContext"; + +export default function FloatingChat() { + const pathname = usePathname(); + const { user, token } = useAuth(); + const { + isOpen, toggleOpen, + view, openView, + aiMessages, aiSending, aiError, setAiError, sendAiMessage, + conversations, convsLoading, loadConversations, + activeConvId, activeConv, liveMessages, liveSending, + openLiveConversation, sendLiveMessage, + startLiveChat, switchingToHuman, + } = useChatWidget(); + + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + if (isOpen) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [aiMessages, liveMessages, isOpen]); + + useEffect(() => { + if (view === "history" && token && isOpen) loadConversations(token); + }, [view, token, isOpen, loadConversations]); + + // Hide widget on dedicated chat pages + if (pathname === "/ai-chat" || pathname === "/chat") return null; + + const openConvCount = conversations.filter((c) => c.status === "OPEN").length; + const isLiveClosed = activeConv?.status === "CLOSED"; + + async function handleSend(e) { + e?.preventDefault(); + const text = input.trim(); + if (!text) return; + setInput(""); + if (view === "ai") { + await sendAiMessage(text, token); + } else if (view === "live" && activeConvId) { + await sendLiveMessage(text, token, activeConvId); + } + } + + function handleKeyDown(e) { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } + } + + return ( + <> + {/* Floating toggle button */} + + + {/* Chat window */} + {isOpen && ( +
+ + {/* Header */} +
+
+
🐾
+
+
Leon's Assistant
+
+ {view === "live" + ? (activeConv?.mode === "HUMAN" ? "Live Support" : "AI Support") + : view === "history" ? "Your Conversations" + : "AI Chat"} +
+
+
+
+ {view !== "ai" && ( + + )} + + +
+
+ + {/* Guest */} + {!user && ( +
+ 🐾 +

+ Log in to chat with our pet assistant! +

+ Log In +
+ )} + + {/* History view */} + {user && view === "history" && ( +
+
+ + +
+ + {convsLoading && ( +

+ Loading… +

+ )} + + {!convsLoading && conversations.length === 0 && ( +
+ 💬 +

No conversations yet.
Start a live chat above.

+
+ )} + + {conversations.map((conv) => ( + + ))} +
+ )} + + {/* Live chat view */} + {user && view === "live" && ( + <> + {activeConv && ( +
+ + {activeConv.status} + + + {activeConv.mode === "HUMAN" ? "👤 Live Support" : "🤖 AI Support"} + + + Full page ↗ + +
+ )} + +
+ {liveMessages.length === 0 && ( +
+

No messages yet.

+
+ )} + {liveMessages.map((msg) => { + const isUser = msg.senderRole === "CUSTOMER"; + return ( +
+ {!isUser && ( +
{msg.senderRole === "BOT" ? "🐾" : "👤"}
+ )} +
+ {!isUser && ( +
+ {msg.senderName || (msg.senderRole === "BOT" ? "AI Bot" : "Staff")} +
+ )} + {msg.content} +
+ {isUser && ( +
+ {user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ); + })} +
+
+ + {isLiveClosed ? ( +
This conversation is closed.
+ ) : ( +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message…" + disabled={liveSending} + autoComplete="off" + /> + +
+ )} + + )} + + {/* AI chat view */} + {user && view === "ai" && ( + <> +
+ Open full page ↗ + +
+ +
+ {aiMessages.length === 0 && ( +
+ 🐾 +

+ Hi{user?.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}!
+ Ask me anything about pets. +

+
+ )} + + {aiMessages.map((msg) => ( +
+ {msg.role === "assistant" &&
🐾
} +
+ {msg.content.split("\n").map((line, i, arr) => ( + {line}{i < arr.length - 1 &&
}
+ ))} +
+ {msg.role === "user" && ( +
+ {user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ))} + + {aiSending && ( +
+
🐾
+
+ + + +
+
+ )} + + {aiError && ( +
+ {aiError} + +
+ )} + +
+
+ +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask about pet care…" + disabled={aiSending} + autoComplete="off" + /> + +
+ + )} + +
+ )} + + ); +} + +// Styles +const s = { + fab: { + position: "fixed", bottom: 24, right: 24, + width: 56, height: 56, borderRadius: "50%", + background: "#e68672", color: "#2f2f2f", border: "none", + cursor: "pointer", zIndex: 9999, + boxShadow: "0 4px 18px rgba(0,0,0,0.22)", + display: "flex", alignItems: "center", justifyContent: "center", + transition: "transform 0.15s, box-shadow 0.15s", + }, + fabBadge: { + position: "absolute", top: 2, right: 2, + background: "#e53935", color: "white", + borderRadius: "999px", fontSize: "0.62rem", fontWeight: 700, + minWidth: 17, height: 17, + display: "flex", alignItems: "center", justifyContent: "center", padding: "0 3px", + }, + window: { + position: "fixed", bottom: 92, right: 24, + width: 370, height: 530, + background: "#fff", borderRadius: 16, + boxShadow: "0 8px 36px rgba(0,0,0,0.18)", + zIndex: 9998, display: "flex", flexDirection: "column", + overflow: "hidden", fontFamily: "Arial, sans-serif", + }, + header: { + background: "#e68672", padding: "0.8rem 1rem", + display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, + }, + headerLeft: { display: "flex", alignItems: "center", gap: "0.6rem" }, + headerAvatar: { + width: 36, height: 36, borderRadius: "50%", + background: "rgba(255,255,255,0.25)", + display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.1rem", + }, + headerTitle: { fontWeight: 700, fontSize: "0.92rem", color: "#2f2f2f" }, + headerSub: { fontSize: "0.7rem", color: "rgba(47,47,47,0.65)", marginTop: 1 }, + headerRight: { display: "flex", alignItems: "center", gap: "0.35rem" }, + headerNavBtn: { + background: "rgba(255,255,255,0.22)", border: "none", borderRadius: 6, + padding: "0.28rem 0.6rem", fontSize: "0.75rem", fontWeight: 600, + color: "#2f2f2f", cursor: "pointer", + }, + headerNavBtnActive: { background: "rgba(255,255,255,0.45)" }, + headerClose: { + background: "transparent", border: "none", + fontSize: "0.95rem", color: "#2f2f2f", cursor: "pointer", padding: "0.2rem 0.35rem", lineHeight: 1, + }, + guestBody: { + flex: 1, display: "flex", flexDirection: "column", + alignItems: "center", justifyContent: "center", padding: "2rem", + }, + loginBtn: { + background: "#e68672", color: "#2f2f2f", + padding: "0.6rem 1.5rem", borderRadius: 8, + textDecoration: "none", fontWeight: 700, fontSize: "0.95rem", + }, + toolbar: { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "0.5rem 0.85rem", borderBottom: "1px solid #f0f0f0", + background: "#fafafa", flexShrink: 0, + }, + fullPageLink: { color: "#e68672", fontSize: "0.76rem", fontWeight: 600, textDecoration: "none" }, + humanBtn: { + background: "transparent", border: "1.5px solid #e68672", color: "#e68672", + borderRadius: 6, padding: "0.28rem 0.65rem", fontSize: "0.76rem", fontWeight: 600, cursor: "pointer", + }, + disabledBtn: { opacity: 0.55, cursor: "not-allowed" }, + messages: { + flex: 1, overflowY: "auto", padding: "0.8rem", + display: "flex", flexDirection: "column", gap: "0.55rem", + }, + empty: { + flex: 1, display: "flex", flexDirection: "column", + alignItems: "center", justifyContent: "center", gap: "0.5rem", margin: "auto", + }, + emptyText: { color: "#aaa", fontSize: "0.88rem", textAlign: "center", lineHeight: 1.5, margin: 0 }, + row: { display: "flex", alignItems: "flex-end", gap: "0.4rem" }, + rowUser: { flexDirection: "row-reverse" }, + rowOther: { flexDirection: "row" }, + otherAvatar: { + width: 26, height: 26, borderRadius: "50%", background: "#f0f0f0", + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: "0.75rem", flexShrink: 0, + }, + userAvatar: { + width: 26, height: 26, borderRadius: "50%", background: "#e68672", color: "#2f2f2f", + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: "0.72rem", fontWeight: 700, flexShrink: 0, + }, + bubble: { + maxWidth: "76%", padding: "0.5rem 0.75rem", borderRadius: 12, + fontSize: "0.86rem", lineHeight: 1.5, wordBreak: "break-word", + }, + bubbleUser: { background: "#e68672", color: "#2f2f2f", borderBottomRightRadius: 4 }, + bubbleOther: { background: "#f4f4f4", color: "#1a1a1a", borderBottomLeftRadius: 4 }, + typingBubble: { display: "flex", alignItems: "center", gap: "4px", padding: "0.65rem 0.85rem" }, + senderName: { fontSize: "0.7rem", fontWeight: 700, color: "#888", marginBottom: 2 }, + errorBar: { + background: "#fff0f0", borderTop: "1px solid #ffd0d0", color: "#c0392b", + padding: "0.5rem 0.85rem", fontSize: "0.8rem", + display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, + }, + errorClose: { background: "none", border: "none", color: "#c0392b", cursor: "pointer" }, + inputRow: { + display: "flex", gap: "0.45rem", padding: "0.6rem 0.85rem", + borderTop: "1px solid #f0f0f0", flexShrink: 0, + }, + input: { + flex: 1, border: "1.5px solid #e0e0e0", borderRadius: 8, + padding: "0.48rem 0.75rem", fontSize: "0.86rem", outline: "none", fontFamily: "inherit", + }, + sendBtn: { + background: "#e68672", color: "#2f2f2f", border: "none", borderRadius: 8, + padding: "0.48rem 0.75rem", fontSize: "0.95rem", fontWeight: 700, cursor: "pointer", flexShrink: 0, + }, + sendBtnDisabled: { background: "#f0c8be", cursor: "not-allowed" }, + historyToolbar: { + display: "flex", gap: "0.5rem", padding: "0.65rem 0.85rem", + borderBottom: "1px solid #f0f0f0", flexShrink: 0, + }, + newChatBtn: { + flex: 1, background: "#e68672", color: "#2f2f2f", border: "none", + borderRadius: 8, padding: "0.5rem", fontSize: "0.8rem", fontWeight: 700, cursor: "pointer", + }, + aiTabBtn: { + background: "#f4f4f4", color: "#555", border: "none", borderRadius: 8, + padding: "0.5rem 0.85rem", fontSize: "0.8rem", fontWeight: 600, cursor: "pointer", + }, + convItem: { + display: "flex", flexDirection: "column", gap: "0.2rem", + padding: "0.7rem 0.85rem", borderBottom: "1px solid #f0f0f0", + background: "white", border: "none", textAlign: "left", cursor: "pointer", width: "100%", + }, + convTop: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem" }, + convBottom: { display: "flex", alignItems: "center", justifyContent: "space-between" }, + convSubject: { + fontSize: "0.86rem", fontWeight: 600, color: "#222", + overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, + }, + convMode: { fontSize: "0.74rem", color: "#999" }, + convDate: { fontSize: "0.7rem", color: "#bbb" }, + statusBadge: { + fontSize: "0.67rem", fontWeight: 700, borderRadius: 20, + padding: "0.13rem 0.5rem", flexShrink: 0, + textTransform: "uppercase", letterSpacing: "0.04em", + }, + statusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, + statusClosed: { background: "#f0f0f0", color: "#888" }, + liveStatus: { + display: "flex", alignItems: "center", gap: "0.5rem", + padding: "0.45rem 0.85rem", borderBottom: "1px solid #f0f0f0", + background: "#fafafa", flexShrink: 0, + }, + closedBanner: { + background: "#f5f5f5", borderTop: "1px solid #e0e0e0", color: "#888", + padding: "0.65rem 0.85rem", fontSize: "0.84rem", textAlign: "center", flexShrink: 0, + }, +}; diff --git a/web/context/ChatWidgetContext.js b/web/context/ChatWidgetContext.js new file mode 100644 index 00000000..0b506dbf --- /dev/null +++ b/web/context/ChatWidgetContext.js @@ -0,0 +1,166 @@ +"use client"; + +import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"; + +const ChatWidgetContext = createContext(null); +const API_BASE = ""; + +export function ChatWidgetProvider({ children }) { + const [isOpen, setIsOpen] = useState(false); + const [view, setView] = useState("ai"); // "ai" | "history" | "live" + + // AI chat + const [aiMessages, setAiMessages] = useState([]); + const [aiSending, setAiSending] = useState(false); + const [aiError, setAiError] = useState(null); + + // Keep a ref so sendAiMessage stays stable (no stale-closure over messages) + const aiMessagesRef = useRef(aiMessages); + useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]); + + const sendAiMessage = useCallback(async (text, token) => { + if (!text.trim() || !token) return; + const userMsg = { role: "user", content: text, id: Date.now() }; + setAiMessages((prev) => [...prev, userMsg]); + setAiSending(true); + setAiError(null); + try { + const history = aiMessagesRef.current.map((m) => ({ role: m.role, content: m.content })); + const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ message: text, history }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { setAiError(data.error || "Failed to get a response."); return; } + setAiMessages((prev) => [...prev, { role: "assistant", content: data.message, id: Date.now() + 1 }]); + } catch { + setAiError("Network error. Please try again."); + } finally { + setAiSending(false); + } + }, []); + + //Live chat + const [conversations, setConversations] = useState([]); + const [convsLoading, setConvsLoading] = useState(false); + const [activeConvId, setActiveConvId] = useState(null); + const [activeConv, setActiveConv] = useState(null); + const [liveMessages, setLiveMessages] = useState([]); + const [liveSending, setLiveSending] = useState(false); + const [switchingToHuman, setSwitchingToHuman] = useState(false); + + const pollRef = useRef(null); + const activeConvIdRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + }, []); + + const fetchLiveMessages = useCallback(async (convId, token) => { + if (!convId || !token) return; + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) setLiveMessages(data); + } catch { /* silent */ } + }, []); + + const loadConversations = useCallback(async (token) => { + if (!token) return; + setConvsLoading(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations?mine=true`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + setConversations(Array.isArray(data) ? data : (data.content ?? [])); + } catch { /* silent */ } finally { + setConvsLoading(false); + } + }, []); + + const openLiveConversation = useCallback(async (convId, token) => { + if (!convId || !token) return; + stopPolling(); + setActiveConvId(convId); + activeConvIdRef.current = convId; + setLiveMessages([]); + setView("live"); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) setActiveConv(await res.json()); + } catch { /* silent */ } + await fetchLiveMessages(convId, token); + pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500); + }, [stopPolling, fetchLiveMessages]); + + const sendLiveMessage = useCallback(async (text, token, convId) => { + if (!text.trim() || liveSending || !token || !convId) return; + setLiveSending(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ content: text }), + }); + if (res.ok) await fetchLiveMessages(convId, token); + } catch { /* silent */ } finally { + setLiveSending(false); + } + }, [liveSending, fetchLiveMessages]); + + const startLiveChat = useCallback(async (token) => { + if (!token || switchingToHuman) return; + setSwitchingToHuman(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ message: "Hi, I'd like to speak with a support agent." }), + }); + if (!res.ok) return; + const conv = await res.json(); + await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + }); + setConversations((prev) => [conv, ...prev.filter((c) => c.id !== conv.id)]); + await openLiveConversation(conv.id, token); + } catch { /* silent */ } finally { + setSwitchingToHuman(false); + } + }, [switchingToHuman, openLiveConversation]); + + // Stop polling when navigating away from live view or closing widget + useEffect(() => { if (view !== "live") stopPolling(); }, [view, stopPolling]); + useEffect(() => { if (!isOpen) stopPolling(); }, [isOpen, stopPolling]); + + const toggleOpen = useCallback(() => setIsOpen((o) => !o), []); + const openView = useCallback((v) => setView(v), []); + + return ( + + {children} + + ); +} + +export function useChatWidget() { + const ctx = useContext(ChatWidgetContext); + if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider"); + return ctx; +}