Responsive Navbar Component with Framer Motion
I updated the Navbar component to improve responsiveness, specifically changing the desktop breakpoint to 1024px. It includes integration with React Router, Framer Motion for animations, and custom hooks for authentication and cart management.user_3179742
jsx
02/10/2026 7:26 AM
30.9 KB
12
Indexable
// File: src/components/layout/Navbar/Navbar.jsx
// RESPONSIVE FIX: Desktop nav now appears at 1024px (lg:) instead of 768px (md:)
// Optimized for: iPhone SE/XR/12 Pro, iPad Mini/Air/Pro, Surface Pro 7, Surface Duo
import React, { useState, useEffect, useCallback } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../../../features/auth/hooks/useAuth";
import { useCart } from "../../../features/cart/hooks/useCart";
import { useAlert } from "../../common/Alert/AlertManager";
import { useMobileMenu } from "../../../contexts/MobileMenuContext";
import SearchBar from "../../../features/search/components/navSearch/SearchBar";
import {
FaShoppingCart,
FaSearch,
FaUser,
FaSignOutAlt,
FaBars,
FaTimes,
FaHome,
FaBox,
FaTh,
FaInfoCircle,
FaEnvelope,
FaChevronDown,
} from "react-icons/fa";
// Optimized ScrollNavLink
const ScrollNavLink = React.memo(({ to, children, ...props }) => (
<NavLink
to={to}
{...props}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
{children}
</NavLink>
));
ScrollNavLink.displayName = "ScrollNavLink";
const Navbar = () => {
const { isMobileMenuOpen, activeMenu, toggleMenu, closeMenu } = useMobileMenu();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const { user, isAuthenticated, logout } = useAuth();
const { items } = useCart();
const { success, error: showError } = useAlert();
const navigate = useNavigate();
// Calculate valid cart items
const validItems = items.filter((item) => item && item.product);
const cartItemCount = validItems.length;
// Scroll handler with throttling
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
setIsScrolled(window.scrollY > 10);
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Profile picture renderer
const renderProfilePicture = useCallback((user, size = "w-8 h-8") => {
if (user?.profilePicture) {
return (
<img
src={user.profilePicture}
alt="Profile"
className={`${size} rounded-full object-cover shadow-md`}
onError={(e) => {
e.target.style.display = "none";
const fallback = e.target.nextSibling;
if (fallback) fallback.style.display = "flex";
}}
/>
);
}
return (
<div
className={`${size} bg-gradient-to-r from-primary-green to-dark-green rounded-full flex items-center justify-center text-white font-semibold shadow-md`}
>
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</div>
);
}, []);
// Event handlers
const handleLogout = useCallback(async () => {
try {
await logout();
setDropdownOpen(false);
closeMenu();
success("Logged out successfully");
} catch (error) {
showError("Logout failed");
}
}, [logout, closeMenu, success, showError]);
const handleProfileClick = useCallback(() => {
navigate("/profile");
setDropdownOpen(false);
closeMenu();
}, [navigate, closeMenu]);
const handleMyOrders = useCallback(() => {
navigate("/orders");
setDropdownOpen(false);
closeMenu();
}, [navigate, closeMenu]);
const handleSearch = useCallback((query) => {
if (query.trim()) {
navigate(`/products?search=${encodeURIComponent(query)}`);
closeMenu();
setMobileSearchOpen(false);
}
}, [navigate, closeMenu]);
const handleSelectProduct = useCallback((product) => {
if (product && product._id) {
navigate(`/product/${product._id}`);
closeMenu();
setMobileSearchOpen(false);
}
}, [navigate, closeMenu]);
const handleMobileMenuToggle = useCallback(() => {
toggleMenu("navbar");
setMobileSearchOpen(false);
}, [toggleMenu]);
const handleMobileLinkClick = useCallback(() => {
closeMenu();
}, [closeMenu]);
const toggleMobileSearch = useCallback(() => {
setMobileSearchOpen((prev) => !prev);
}, []);
// Keyboard support
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape") {
if (dropdownOpen) setDropdownOpen(false);
if (mobileSearchOpen) setMobileSearchOpen(false);
if (isMobileMenuOpen && activeMenu === "navbar") closeMenu();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [dropdownOpen, mobileSearchOpen, isMobileMenuOpen, activeMenu, closeMenu]);
// Body scroll lock for mobile menu
useEffect(() => {
const isNavbarMenuOpen = isMobileMenuOpen && activeMenu === "navbar";
if (isNavbarMenuOpen) {
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
} else {
const scrollY = document.body.style.top;
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || "0") * -1);
}
}
return () => {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
};
}, [isMobileMenuOpen, activeMenu]);
const isNavbarMenuOpen = isMobileMenuOpen && activeMenu === "navbar";
// Animation variants
const mobileMenuVariants = {
hidden: { x: "100%", opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: { type: "spring", stiffness: 300, damping: 30 },
},
exit: {
x: "100%",
opacity: 0,
transition: { duration: 0.2 },
},
};
const mobileSearchVariants = {
hidden: { height: 0, opacity: 0 },
visible: {
height: "auto",
opacity: 1,
transition: { duration: 0.3, ease: "easeOut" },
},
exit: {
height: 0,
opacity: 0,
transition: { duration: 0.2, ease: "easeIn" },
},
};
const dropdownVariants = {
hidden: { opacity: 0, y: -10, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 500, damping: 30 },
},
exit: { opacity: 0, y: -10, scale: 0.95, transition: { duration: 0.15 } },
};
return (
<>
{/* Main Navbar */}
<nav
className={`sticky top-0 z-50 bg-white transition-all duration-300 ${
isScrolled ? "shadow-lg" : "shadow-sm border-b border-gray-200"
}`}
>
<div className="max-w-7xl mx-auto px-3 sm:px-4 lg:px-6">
{/*
RESPONSIVE BREAKPOINTS:
- Mobile (< 640px): Logo + icons only
- Small tablets (640px-1023px): Logo text appears, still mobile menu
- Desktop (≥ 1024px): Full navigation
*/}
<div className="flex items-center justify-between h-14 sm:h-16 lg:h-18">
{/* Logo Section */}
<ScrollNavLink
to="/"
className="flex-shrink-0 hover:opacity-80 transition-opacity flex items-center gap-2 sm:gap-3"
aria-label="Chambi Nuts Home"
>
<motion.img
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
src="../../../public/logo/chambinuts.png"
alt="Chambi Nuts Logo"
className="h-7 w-auto sm:h-9 lg:h-10 object-contain rounded-lg"
/>
{/* Logo text: visible on all screens with responsive sizing */}
<span className="text-xs min-[375px]:text-sm sm:text-base lg:text-lg xl:text-xl font-bold text-dark-charcoal">
CHAMBI NUTS
</span>
</ScrollNavLink>
{/* Desktop Navigation - Only shows at lg (1024px+) */}
<div className="hidden lg:flex items-center space-x-4 xl:space-x-6 flex-1 justify-center">
{[
{ path: "/", label: "Home" },
{ path: "/products", label: "Products" },
{ path: "/categories", label: "Categories" },
{ path: "/about", label: "About" },
{ path: "/contact", label: "Contact" },
].map(({ path, label }) => (
<ScrollNavLink
key={path}
to={path}
className={({ isActive }) =>
`relative px-2 xl:px-3 py-2 text-sm xl:text-base font-medium transition-colors ${
isActive
? "text-primary-green"
: "text-gray-700 hover:text-primary-green"
}`
}
>
{({ isActive }) => (
<>
{label}
{isActive && (
<motion.div
layoutId="navbar-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-green rounded-full"
/>
)}
</>
)}
</ScrollNavLink>
))}
</div>
{/* Desktop Actions - Only shows at lg (1024px+) */}
<div className="hidden lg:flex items-center space-x-3 xl:space-x-4">
<SearchBar
onSearch={handleSearch}
onSelectProduct={handleSelectProduct}
placeholder="Search products..."
className="w-40 xl:w-52 flex-shrink-0"
/>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} className="relative">
<ScrollNavLink
to="/cart"
className="flex items-center justify-center p-2 hover:bg-gray-100 rounded-full transition-colors"
aria-label={`Shopping cart with ${cartItemCount} items`}
>
<FaShoppingCart className="text-primary-green text-lg xl:text-xl" />
</ScrollNavLink>
<AnimatePresence>
{cartItemCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow-sm pointer-events-none"
>
{cartItemCount > 9 ? "9+" : cartItemCount}
</motion.span>
)}
</AnimatePresence>
</motion.div>
{isAuthenticated ? (
<div className="relative">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center space-x-2 p-2 hover:bg-gray-100 rounded-full transition-colors"
>
{renderProfilePicture(user)}
<FaChevronDown
className={`text-gray-600 transition-transform ${
dropdownOpen ? "rotate-180" : ""
}`}
size={12}
/>
</motion.button>
<AnimatePresence>
{dropdownOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setDropdownOpen(false)}
/>
<motion.div
variants={dropdownVariants}
initial="hidden"
animate="visible"
exit="exit"
className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl border border-gray-100 py-2 z-20"
>
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900 truncate">
{user?.name || "User"}
</p>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div>
<button
onClick={handleProfileClick}
className="w-full px-4 py-3 text-left text-sm hover:bg-gray-50 flex items-center space-x-3"
>
<FaUser className="text-primary-green" />
<span>My Profile</span>
</button>
<button
onClick={handleMyOrders}
className="w-full px-4 py-3 text-left text-sm hover:bg-gray-50 flex items-center space-x-3"
>
<FaBox className="text-primary-green" />
<span>My Orders</span>
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-3 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-3"
>
<FaSignOutAlt />
<span>Logout</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
) : (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => window.dispatchEvent(new CustomEvent("openLoginModal"))}
className="px-5 xl:px-6 py-2 bg-gradient-to-r from-primary-green to-dark-green text-white rounded-full text-sm font-medium shadow-md"
>
Login
</motion.button>
)}
</div>
{/* Mobile/Tablet Actions - Shows below lg (< 1024px) */}
<div className="flex lg:hidden items-center space-x-1.5 sm:space-x-2">
<motion.button
whileTap={{ scale: 0.9 }}
onClick={toggleMobileSearch}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Search"
>
<FaSearch className="text-primary-green text-base sm:text-lg" />
</motion.button>
<motion.div whileTap={{ scale: 0.9 }} className="relative">
<ScrollNavLink
to="/cart"
className="flex items-center justify-center p-2 rounded-full hover:bg-gray-100 transition-colors"
aria-label={`Cart with ${cartItemCount} items`}
>
<FaShoppingCart className="text-primary-green text-base sm:text-lg" />
</ScrollNavLink>
{cartItemCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 shadow-sm pointer-events-none">
{cartItemCount > 9 ? "9+" : cartItemCount}
</span>
)}
</motion.div>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={handleMobileMenuToggle}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Menu"
>
{isNavbarMenuOpen ? (
<FaTimes className="text-primary-green text-base sm:text-lg" />
) : (
<FaBars className="text-primary-green text-base sm:text-lg" />
)}
</motion.button>
</div>
</div>
</div>
{/* Mobile Search Dropdown - Shows below lg (< 1024px) */}
<AnimatePresence>
{mobileSearchOpen && (
<motion.div
variants={mobileSearchVariants}
initial="hidden"
animate="visible"
exit="exit"
className="lg:hidden border-t border-gray-200 bg-gray-50 overflow-visible"
>
<div className="p-3 sm:p-4 relative">
<SearchBar
onSearch={handleSearch}
onSelectProduct={handleSelectProduct}
placeholder="Search products..."
className="w-full"
autoFocus={true}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isNavbarMenuOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 lg:hidden"
onClick={closeMenu}
/>
)}
</AnimatePresence>
{/* Mobile Sidebar Menu */}
<AnimatePresence>
{isNavbarMenuOpen && (
<motion.div
variants={mobileMenuVariants}
initial="hidden"
animate="visible"
exit="exit"
className="fixed top-0 right-0 bottom-0 w-[280px] sm:w-[320px] bg-white shadow-2xl z-50 lg:hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-bold text-gray-900">Menu</h2>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={closeMenu}
className="p-2 rounded-full hover:bg-gray-100"
>
<FaTimes className="text-gray-600" />
</motion.button>
</div>
{/* User Profile Section */}
{isAuthenticated && (
<div className="p-4 bg-gradient-to-r from-primary-green/5 to-dark-green/5 border-b border-gray-200">
<div className="flex items-center space-x-3">
{renderProfilePicture(user, "w-12 h-12")}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{user?.name || "User"}
</p>
<p className="text-xs text-gray-600 truncate">{user?.email}</p>
</div>
</div>
</div>
)}
{/* Navigation Links */}
<div className="flex-1 overflow-y-auto">
<nav className="p-4 space-y-1">
{[
{ path: "/", label: "Home", icon: FaHome },
{ path: "/products", label: "Products", icon: FaBox },
{ path: "/categories", label: "Categories", icon: FaTh },
{ path: "/about", label: "About", icon: FaInfoCircle },
{ path: "/contact", label: "Contact", icon: FaEnvelope },
].map(({ path, label, icon: Icon }) => (
<ScrollNavLink
key={path}
to={path}
onClick={handleMobileLinkClick}
className={({ isActive }) =>
`flex items-center justify-between px-4 py-3 rounded-lg transition-all ${
isActive
? "bg-gradient-to-r from-primary-green to-dark-green text-white shadow-md"
: "text-gray-700 hover:bg-gray-100"
}`
}
>
<div className="flex items-center space-x-3">
<Icon size={18} />
<span className="font-medium">{label}</span>
</div>
</ScrollNavLink>
))}
</nav>
{/* User Actions */}
{isAuthenticated && (
<div className="p-4 space-y-1 border-t border-gray-200">
<button
onClick={handleProfileClick}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-3">
<FaUser className="text-primary-green" size={18} />
<span className="font-medium">My Profile</span>
</div>
</button>
<button
onClick={handleMyOrders}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-3">
<FaBox className="text-primary-green" size={18} />
<span className="font-medium">My Orders</span>
</div>
</button>
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-gray-200 bg-gray-50">
{isAuthenticated ? (
<button
onClick={handleLogout}
className="w-full px-4 py-3 bg-red-50 text-red-600 rounded-lg font-medium hover:bg-red-100 transition-colors flex items-center justify-center space-x-2"
>
<FaSignOutAlt />
<span>Logout</span>
</button>
) : (
<button
onClick={() => {
window.dispatchEvent(new CustomEvent("openLoginModal"));
closeMenu();
}}
className="w-full px-4 py-3 bg-gradient-to-r from-primary-green to-dark-green text-white rounded-lg font-semibold shadow-md"
>
Login to Continue
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default React.memo(Navbar);Editor is loading...
Leave a Comment