Update sidebar

This commit is contained in:
Thanh Quy - wolf
2026-03-22 17:16:08 +07:00
parent 4cc1e76dd7
commit b980c12afd
6 changed files with 155 additions and 44 deletions
+11 -8
View File
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Header from "@/layouts/header"; import Header from "@/layouts/header";
import Footer from "@/layouts/footer"; import Footer from "@/layouts/footer";
import { Providers } from "./providers";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -43,16 +44,18 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`} className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
> >
{/* Sticky top header */} <Providers>
<Header /> {/* Sticky top header */}
<Header />
{/* Page content (grows to fill remaining height) */} {/* Page content (grows to fill remaining height) */}
<div className="flex-1"> <div className="flex-1">
{children} {children}
</div> </div>
{/* Footer always at bottom */} {/* Footer always at bottom */}
<Footer /> <Footer />
</Providers>
</body> </body>
</html> </html>
); );
+47 -5
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import CartProduct from "@/components/CartProduct"; import CartProduct from "@/components/CartProduct";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants"; import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
/** /**
* Main page — sidebar + product grid layout. * Main page — sidebar + product grid layout.
@@ -20,10 +21,13 @@ import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
* Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4 * Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4
*/ */
export default function Home() { export default function Home() {
/* Shared category state comes from MenuContext so the header mobile menu
* and this sidebar always reflect the same selection. */
const { activeCategory, setActiveCategory } = useMenu();
/* Start collapsed (false) so SSR and client initial render match. /* Start collapsed (false) so SSR and client initial render match.
* useEffect sets the correct value after hydration completes. */ * useEffect sets the correct value after hydration completes. */
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
/* After mount: sync sidebar with viewport width, then subscribe to changes. /* After mount: sync sidebar with viewport width, then subscribe to changes.
@@ -38,6 +42,14 @@ export default function Home() {
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
}, []); }, []);
/* Clear search whenever the active category changes (triggered from either
* the sidebar on md+ or the header scrollable menu on < md).
* setState-in-effect is intentional here — suppress the lint rule. */
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchQuery("");
}, [activeCategory]);
/* Filter products by availability, active category, and search query. /* Filter products by availability, active category, and search query.
* p.available defaults to true when undefined (opt-in unavailability). */ * p.available defaults to true when undefined (opt-in unavailability). */
const filteredProducts = MOCK_PRODUCTS.filter((p) => { const filteredProducts = MOCK_PRODUCTS.filter((p) => {
@@ -73,10 +85,7 @@ export default function Home() {
isOpen={isSidebarOpen} isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)} onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory} activeCategory={activeCategory}
onCategoryChange={(id) => { onCategoryChange={setActiveCategory}
setActiveCategory(id);
setSearchQuery(""); // clear search when switching category
}}
/> />
{/* ── Main content ── */} {/* ── Main content ── */}
@@ -128,6 +137,39 @@ export default function Home() {
</div> </div>
</div> </div>
{/* ── Mobile category menu — visible only on < md, below search, above products ── */}
<div className="md:hidden -mx-4 px-4 overflow-x-auto mb-4">
<div className="flex items-center gap-1.5 pb-1">
{MENU_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`
flex items-center gap-1.5 px-3 py-2 rounded-xl
text-sm font-medium whitespace-nowrap shrink-0
cursor-pointer border-none transition-all duration-150
${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
}
`}
>
<i
className={`
${cat.icon} text-sm shrink-0
${isActive ? "text-white" : "text-(--color-primary)"}
`}
></i>
<span>{cat.name}</span>
</button>
);
})}
</div>
</div>
{/* ── Product grid ── */} {/* ── Product grid ── */}
{filteredProducts.length > 0 ? ( {filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}> <div className={`grid gap-4 ${gridCols}`}>
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { MenuProvider } from "@/lib/menu-context";
/**
* Client-side providers wrapper.
* Placed here so the server-only app/layout.tsx can wrap its children
* with client context providers without becoming a client component itself.
*/
export function Providers({ children }: { children: React.ReactNode }) {
return <MenuProvider>{children}</MenuProvider>;
}
+49 -29
View File
@@ -32,11 +32,13 @@ export default function Navbar({
return ( return (
<aside <aside
className={` className={`
sticky shrink-0 flex flex-col z-20 sticky shrink-0 flex-col z-20
border-r border-(--color-border) border-r border-(--color-border)
bg-(--color-bg-sidebar) bg-(--color-bg-sidebar)
overflow-y-auto overflow-x-hidden overflow-y-auto overflow-x-hidden
transition-all duration-250 ease-in-out transition-all duration-250 ease-in-out
hidden md:flex
xl:w-60
${isOpen ? "w-60" : "w-16"} ${isOpen ? "w-60" : "w-16"}
`} `}
style={{ style={{
@@ -48,22 +50,31 @@ export default function Navbar({
<div <div
className={` className={`
flex items-center border-b border-(--color-border) shrink-0 flex items-center border-b border-(--color-border) shrink-0
xl:justify-between xl:px-4 xl:py-3
${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"} ${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"}
`} `}
> >
{isOpen && ( {/* Title — shown when expanded, always shown on xl+ */}
<span className="text-xs font-bold uppercase tracking-widest text-(--color-text-muted) whitespace-nowrap"> <span
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i> className={`
Thực Đơn text-xs font-bold uppercase tracking-widest
</span> text-(--color-text-muted) whitespace-nowrap
)} ${isOpen ? "block" : "hidden"} xl:block
`}
>
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
Thực Đơn
</span>
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
<button <button
onClick={onToggle} onClick={onToggle}
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"} title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
className="w-8 h-8 flex items-center justify-center rounded-lg cursor-pointer border-none className="w-8 h-8 flex items-center justify-center rounded-lg cursor-pointer border-none
text-(--color-text-muted) bg-transparent text-(--color-text-muted) bg-transparent
hover:bg-(--color-border-light) hover:text-(--color-primary) hover:bg-(--color-border-light) hover:text-(--color-primary)
transition-colors duration-150 shrink-0" transition-colors duration-150 shrink-0
xl:hidden"
> >
<i <i
className={`fa-solid text-sm transition-transform duration-250 ${ className={`fa-solid text-sm transition-transform duration-250 ${
@@ -86,6 +97,7 @@ export default function Navbar({
className={` className={`
w-full flex items-center rounded-xl text-sm font-medium w-full flex items-center rounded-xl text-sm font-medium
cursor-pointer border-none transition-all duration-150 cursor-pointer border-none transition-all duration-150
xl:gap-3 xl:px-3 xl:py-2.5 xl:justify-start
${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"}
${ ${
isActive isActive
@@ -102,17 +114,16 @@ export default function Navbar({
`} `}
></i> ></i>
{/* Label — hidden when collapsed */} {/* Label — hidden when collapsed, always shown on xl+ */}
{isOpen && ( <span
<span className="whitespace-nowrap overflow-hidden text-ellipsis"> className={`
{cat.name} whitespace-nowrap overflow-hidden text-ellipsis
</span> ${isOpen ? "block" : "hidden"} xl:block
)} `}
>
{cat.name}
</span>
{/* Active indicator arrow */}
{isOpen && isActive && (
<i className="fa-solid fa-chevron-right ml-auto text-xs opacity-70 shrink-0"></i>
)}
</button> </button>
</li> </li>
); );
@@ -124,20 +135,29 @@ export default function Navbar({
<div <div
className={` className={`
shrink-0 border-t border-(--color-border) py-3 shrink-0 border-t border-(--color-border) py-3
xl:px-4
${isOpen ? "px-4" : "px-0 flex justify-center"} ${isOpen ? "px-4" : "px-0 flex justify-center"}
`} `}
> >
{isOpen ? ( {/* Text row — shown when expanded, always shown on xl+ */}
<div className="flex items-center gap-2 text-xs text-(--color-text-muted)"> <div
<i className="fa-solid fa-clock text-(--color-accent) shrink-0"></i> className={`
<span>{SHOP_INFO.openHours}</span> items-center gap-2 text-xs text-(--color-text-muted)
</div> ${isOpen ? "flex" : "hidden"} xl:flex
) : ( `}
<i >
className="fa-solid fa-clock text-sm text-(--color-text-muted)" <i className="fa-solid fa-clock text-(--color-accent) shrink-0"></i>
title={SHOP_INFO.openHours} <span>{SHOP_INFO.openHours}</span>
></i> </div>
)}
{/* Icon-only — shown when collapsed, hidden on xl+ */}
<i
className={`
fa-solid fa-clock text-sm text-(--color-text-muted)
${isOpen ? "hidden" : "block"} xl:hidden
`}
title={SHOP_INFO.openHours}
></i>
</div> </div>
</aside> </aside>
); );
+2 -2
View File
@@ -195,9 +195,9 @@ export default function Footer() {
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved. © {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
Made with{" "} Đưc vận hành {" "}
<i className="fa-solid fa-heart text-(--color-accent) mx-1"></i>{" "} <i className="fa-solid fa-heart text-(--color-accent) mx-1"></i>{" "}
in Vietnam bằng Drinkool
</span> </span>
</div> </div>
</div> </div>
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { createContext, useContext, useState } from "react";
interface MenuContextType {
/** Currently selected category id */
activeCategory: string;
/** Update the active category */
setActiveCategory: (id: string) => void;
}
const MenuContext = createContext<MenuContextType>({
activeCategory: "all",
setActiveCategory: () => {},
});
/**
* Provides shared activeCategory state to both the Header (mobile scrollable menu)
* and the Navbar sidebar (md+), so both always reflect the same selection.
*/
export function MenuProvider({ children }: { children: React.ReactNode }) {
const [activeCategory, setActiveCategory] = useState("all");
return (
<MenuContext.Provider value={{ activeCategory, setActiveCategory }}>
{children}
</MenuContext.Provider>
);
}
/** Consume the shared menu state anywhere inside MenuProvider */
export function useMenu() {
return useContext(MenuContext);
}