327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { useSocket } from "../contexts/SocketContext";
|
|
import { rentalAPI, messageAPI } from "../services/api";
|
|
|
|
const Navbar: React.FC = () => {
|
|
const { user, logout, openAuthModal } = useAuth();
|
|
const { onNewMessage, onMessageRead } = useSocket();
|
|
const navigate = useNavigate();
|
|
const [searchFilters, setSearchFilters] = useState({
|
|
search: "",
|
|
location: "",
|
|
});
|
|
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
|
const [unreadMessagesCount, setUnreadMessagesCount] = useState(0);
|
|
|
|
// Fetch pending rental requests count when user logs in
|
|
useEffect(() => {
|
|
const fetchPendingCount = async () => {
|
|
if (user) {
|
|
try {
|
|
const response = await rentalAPI.getPendingRequestsCount();
|
|
setPendingRequestsCount(response.data.count);
|
|
} catch (error) {
|
|
console.error("Failed to fetch pending requests count:", error);
|
|
}
|
|
} else {
|
|
setPendingRequestsCount(0);
|
|
}
|
|
};
|
|
|
|
fetchPendingCount();
|
|
|
|
// Listen for rental status changes to refresh count
|
|
const handleRentalStatusChange = () => {
|
|
fetchPendingCount();
|
|
};
|
|
|
|
window.addEventListener("rentalStatusChanged", handleRentalStatusChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("rentalStatusChanged", handleRentalStatusChange);
|
|
};
|
|
}, [user]);
|
|
|
|
// Fetch unread messages count when user logs in
|
|
useEffect(() => {
|
|
const fetchUnreadCount = async () => {
|
|
if (user) {
|
|
try {
|
|
const response = await messageAPI.getUnreadCount();
|
|
setUnreadMessagesCount(response.data.count);
|
|
} catch (error) {
|
|
console.error("Failed to fetch unread message count:", error);
|
|
}
|
|
} else {
|
|
setUnreadMessagesCount(0);
|
|
}
|
|
};
|
|
|
|
fetchUnreadCount();
|
|
}, [user]);
|
|
|
|
// Listen for real-time message updates via socket
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
// Listen for new messages
|
|
const cleanupNewMessage = onNewMessage((message: any) => {
|
|
if (message.receiverId === user.id) {
|
|
setUnreadMessagesCount((prev) => prev + 1);
|
|
}
|
|
});
|
|
|
|
// Listen for messages being read
|
|
const cleanupMessageRead = onMessageRead(() => {
|
|
setUnreadMessagesCount((prev) => Math.max(0, prev - 1));
|
|
});
|
|
|
|
return () => {
|
|
cleanupNewMessage();
|
|
cleanupMessageRead();
|
|
};
|
|
}, [user, onNewMessage, onMessageRead]);
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
navigate("/");
|
|
};
|
|
|
|
const handleSearch = (e?: React.FormEvent | React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
const params = new URLSearchParams();
|
|
|
|
if (searchFilters.search.trim()) {
|
|
params.append("search", searchFilters.search.trim());
|
|
}
|
|
if (searchFilters.location.trim()) {
|
|
// Check if location looks like a zip code (5 digits) or city name
|
|
const location = searchFilters.location.trim();
|
|
if (/^\d{5}(-\d{4})?$/.test(location)) {
|
|
params.append("zipCode", location);
|
|
} else {
|
|
params.append("city", location);
|
|
}
|
|
}
|
|
|
|
const queryString = params.toString();
|
|
navigate(`/items${queryString ? `?${queryString}` : ""}`);
|
|
|
|
// Clear search after navigating
|
|
setSearchFilters({ search: "", location: "" });
|
|
};
|
|
|
|
const handleSearchInputChange = (
|
|
field: "search" | "location",
|
|
value: string
|
|
) => {
|
|
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
|
|
<div className="container-fluid" style={{ maxWidth: "1800px" }}>
|
|
<Link className="navbar-brand fw-bold" to="/">
|
|
<i className="bi bi-box-seam me-2"></i>
|
|
Village Share
|
|
</Link>
|
|
<button
|
|
className="navbar-toggler"
|
|
type="button"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="#navbarNav"
|
|
aria-controls="navbarNav"
|
|
aria-expanded="false"
|
|
aria-label="Toggle navigation"
|
|
>
|
|
<span className="navbar-toggler-icon"></span>
|
|
</button>
|
|
<div className="collapse navbar-collapse" id="navbarNav">
|
|
<div className="d-flex align-items-center w-100">
|
|
<div className="position-absolute start-50 translate-middle-x">
|
|
<div>
|
|
<div className="input-group" style={{ width: "520px" }}>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
placeholder="Search items..."
|
|
value={searchFilters.search}
|
|
onChange={(e) =>
|
|
handleSearchInputChange("search", e.target.value)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch(e);
|
|
}
|
|
}}
|
|
/>
|
|
<span
|
|
className="input-group-text text-muted"
|
|
style={{
|
|
borderLeft: "0",
|
|
borderRight: "1px solid #dee2e6",
|
|
backgroundColor: "#f8f9fa",
|
|
}}
|
|
>
|
|
in
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
placeholder="City or ZIP"
|
|
value={searchFilters.location}
|
|
onChange={(e) =>
|
|
handleSearchInputChange("location", e.target.value)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch(e);
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={handleSearch}
|
|
>
|
|
<i className="bi bi-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="ms-auto d-flex align-items-center">
|
|
<Link
|
|
className="btn btn-outline-primary btn-sm me-3 text-nowrap"
|
|
to="/create-item"
|
|
>
|
|
Start Earning
|
|
</Link>
|
|
<ul className="navbar-nav flex-row">
|
|
{user ? (
|
|
<>
|
|
<li className="nav-item dropdown">
|
|
<a
|
|
className="nav-link dropdown-toggle"
|
|
href="#"
|
|
id="navbarDropdown"
|
|
role="button"
|
|
data-bs-toggle="dropdown"
|
|
aria-expanded="false"
|
|
>
|
|
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
|
|
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
left: "-0.9em",
|
|
top: "50%",
|
|
transform: "translateY(-50%)",
|
|
backgroundColor: "#dc3545",
|
|
color: "white",
|
|
borderRadius: "50%",
|
|
width: "1.5em",
|
|
height: "1.5em",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "0.85em",
|
|
fontWeight: "bold",
|
|
border: "2px solid white",
|
|
opacity: 1,
|
|
zIndex: 1,
|
|
}}
|
|
>
|
|
{pendingRequestsCount + unreadMessagesCount}
|
|
</span>
|
|
)}
|
|
<i className="bi bi-person-circle me-1"></i>
|
|
{user.firstName}
|
|
</span>
|
|
</a>
|
|
<ul
|
|
className="dropdown-menu"
|
|
aria-labelledby="navbarDropdown"
|
|
>
|
|
<li>
|
|
<Link className="dropdown-item" to="/profile">
|
|
<i className="bi bi-person me-2"></i>Profile
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link className="dropdown-item" to="/renting">
|
|
<i className="bi bi-calendar-check me-2"></i>
|
|
Renting
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link className="dropdown-item" to="/owning">
|
|
<i className="bi bi-list-ul me-2"></i>Owning
|
|
{pendingRequestsCount > 0 && (
|
|
<span className="badge bg-danger rounded-pill ms-2">
|
|
{pendingRequestsCount}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link className="dropdown-item" to="/forum">
|
|
<i className="bi bi-chat-dots me-2"></i>
|
|
Forum
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link className="dropdown-item" to="/earnings">
|
|
<i className="bi bi-cash-coin me-2"></i>
|
|
Earnings
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link className="dropdown-item" to="/messages">
|
|
<i className="bi bi-envelope me-2"></i>Messages
|
|
{unreadMessagesCount > 0 && (
|
|
<span className="badge bg-danger rounded-pill ms-2">
|
|
{unreadMessagesCount}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<hr className="dropdown-divider" />
|
|
</li>
|
|
<li>
|
|
<button
|
|
className="dropdown-item"
|
|
onClick={handleLogout}
|
|
>
|
|
<i className="bi bi-box-arrow-right me-2"></i>
|
|
Logout
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</>
|
|
) : (
|
|
<li className="nav-item">
|
|
<button
|
|
className="btn btn-primary btn-sm text-nowrap"
|
|
onClick={() => openAuthModal("login")}
|
|
>
|
|
Login or Sign Up
|
|
</button>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Navbar;
|