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 avatar
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