Update sidebar
This commit is contained in:
+11
-8
@@ -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
@@ -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}`}>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user