Fix some error

This commit is contained in:
Thanh Quy - wolf
2026-03-22 15:47:10 +07:00
parent 9da99c6495
commit 4cc1e76dd7
12 changed files with 360 additions and 207 deletions
+56 -55
View File
@@ -1,25 +1,24 @@
# Hệ Thống Bán Đồ Ăn - Frontend
# Coffee Shop Frontend
Dự án Frontend cho một Hệ thống bán đồ ăn (Food Ordering System), được xây dựng bằng Next.js và React, với giao diện hiện đại sử dụng Tailwind CSS.
Dự án Frontend cho hệ thống đặt món cà phê, xây dựng bằng Next.js App Router, React 19, TypeScript và Tailwind CSS v4.
---
## Mô Tả Dự Án
Đây là giao diện người dùng (frontend) cho hệ thống đặt và bán đồ ăn trực tuyến. Hệ thống gồm 2 trang chính:
Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ uống trực tuyến.
### Trang Người Dùng (User Page)
Dành cho khách hàng với các chức năng:
- Chọn món ăn: Duyệt và thêm món vào giỏ hàng
- Xem hóa đơn: Xem chi tiết các món đã chọn và tổng tiền
- Thanh toán: Thực hiện thanh toán đơn hàng
- Đánh giá: Đánh giá chất lượng món ăn và dịch vụ
### Trang Người Dùng (User Page - /)
Dành cho khách hàng:
- Duyệt thực đơn theo danh mục (sidebar collapsible)
- Tìm kiếm món theo tên / mô tả
- Xem card sản phẩm với giá và nút Mua
- Lọc tự động theo trạng thái available
### Trang Quản Lý (Manager Page)
Dành cho chủ quán / nhân viên quản lý với các chức năng:
- Quản lý thực đơn (thêm, sửa, xóa món ăn)
### Trang Quản Lý (Manager Page - chưa triển khai)
Dành cho chủ quán / nhân viên:
- Quản lý thực đơn (thêm, sửa, xóa món)
- Theo dõi và xử lý đơn hàng
- Quản lý thông tin quán
---
@@ -27,56 +26,59 @@ Dành cho chủ quán / nhân viên quản lý với các chức năng:
### Yêu cầu hệ thống
- Node.js >= 18
- pnpm (khuyến nghị) hoặc npm / yarn / bun
### Cài đặt dependencies
- pnpm (khuyến nghị) hoặc npm
### Cài đặt
```bash
pnpm install
# hoặc
npm install
```
### Chạy môi trường phát triển
### Dev
```bash
pnpm dev
# hoặc
npm run dev
```
Mở trình duyệt tại http://localhost:3000
### Build
```bash
pnpm build && pnpm start
```
Mở trình duyệt tại http://localhost:3000 để xem kết quả.
### Build production
### Lint
```bash
pnpm build
pnpm start
pnpm lint
```
---
## Cấu Trúc Thư Mục
Dự án sử dụng cấu trúc mặc định của Next.js App Router:
```
frondend/
├── app/ # App Router (Next.js)
├── layout.tsx # Root layout (font, metadata, global styles)
├── page.tsx # Trang chủ (User Page)
├── globals.css # CSS toàn cục (Tailwind import)
│ ├── favicon.ico # Icon trang web
│ └── components/ # Components dùng riêng trong app/
├── components/ # Shared components dùng chung toàn dự án
│ └── CartProduct.tsx # Component card sản phẩm trong giỏ hàng
├── public/ # Static assets (ảnh, svg, ...)
├── next.config.ts # Cấu hình Next.js
├── tsconfig.json # Cấu hình TypeScript
├── postcss.config.mjs # Cấu hình PostCSS (Tailwind)
├── eslint.config.mjs # Cấu hình ESLint
├── package.json # Dependencies và scripts
└── pnpm-workspace.yaml # Cấu hình pnpm workspace
+-- app/ # Next.js App Router
| +-- layout.tsx # Root layout
| +-- page.tsx # Trang chủ - sidebar + product grid
| +-- globals.css # CSS design tokens + Tailwind import
+-- components/ # Shared UI components
| +-- Navbar.tsx # Sidebar danh mục (collapsible)
| +-- CartProduct.tsx # Card sản phẩm
| +-- COMPONENTS.md # Tài liệu component
+-- layouts/ # Layout-level components
| +-- header.tsx # Sticky top header
| +-- footer.tsx # Footer
+-- lib/ # Shared logic & data
| +-- constants.ts # Mock data
| +-- types.ts # TypeScript interfaces
+-- types/ # Global TypeScript declarations
| +-- css.d.ts # CSS module type shim
+-- public/ # Static assets
+-- WORKFLOW.md # Tài liệu kiến trúc & quy trình
+-- next.config.ts
+-- tsconfig.json
+-- postcss.config.mjs
+-- eslint.config.mjs
+-- package.json
+-- pnpm-workspace.yaml
```
---
@@ -89,19 +91,18 @@ frondend/
| React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4 | Utility-first CSS framework |
| SCSS | - | CSS preprocessor |
| Geist Font | - | Font chữ mặc định (Google Fonts) |
| Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager |
| ESLint | ^9 | Linting code |
| ESLint | ^9 | Linting |
---
## Ghi Chú Phát Triển
- Trang chủ () là điểm vào chính của **User Page**
- Chỉnh sửa để thay đổi giao diện trang chủ; trang tự động cập nhật khi lưu file
- Styles toàn cục được định nghĩa trong
- Tailwind CSS v4 được import trực tiếp qua trong CSS
- Hỗ trợ **Dark Mode** tự động theo cài đặt hệ thống ()
- Font chữ sử dụng **Geist Sans****Geist Mono** từ Google Fonts thông qua
- Trang chủ (app/page.tsx) là điểm vào chính của User Page
- Design tokens định nghĩa trong app/globals.css dưới dạng CSS custom properties
- Mock data nằm trong lib/constants.ts - thay bằng API calls khi backend sẵn sàng
- Dark mode: biến CSS đã chuẩn bị sẵn trong globals.css nhưng chưa kích hoạt
- Ảnh sản phẩm: thêm ảnh thực vào public/imgs/products/
- Xem WORKFLOW.md để hiểu kiến trúc tổng thể và quy trình mở rộng dự án
+52 -1
View File
@@ -1 +1,52 @@
# Food Ordering Page - Implementation TODO
# Coffee Shop Frontend - TODO
## Completed Optimizations
### A. Dead Code Removed
- [x] lib/constants.ts - Removed unused NAV_LINKS export
- [x] lib/types.ts - Removed unused NavLink interface
- [x] components/Navbar.tsx - Removed trivial handleClick wrapper; inlined onCategoryChange call
- [x] components/Navbar.tsx - Removed unused Link import
### B. Bugs / Inaccuracies Fixed
- [x] layouts/header.tsx - Fixed JSDoc: 3-column -> 2-column layout (no center section exists)
- [x] app/page.tsx - Added available !== false filter to product list
- [x] app/page.tsx - Fixed setState-in-effect lint error: moved initial sidebar state to lazy useState initializer
- [x] next.config.ts - Added explanatory JSDoc comment
### C. Documentation Updated
- [x] README.md - Fixed file structure tree, removed SCSS, fixed dark mode note, updated tech table
- [x] components/COMPONENTS.md - Fixed CartProduct styling (was outdated w-64/text-red-500/bg-blue-600); added Navbar, Header, Footer sections
### D. New Documentation Created
- [x] WORKFLOW.md - Architecture, data flow, design token system, how-to guides, dev workflow
### E. Mini-test Results
- [x] npm run lint - PASSED (0 errors, 0 warnings)
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static pages generated)
---
## Pending Features (Future Work)
### Cart & Ordering
- [ ] Implement add-to-cart logic (onBuy callback in CartProduct)
- [ ] Cart sidebar or modal with item list and total
- [ ] Order submission flow
- [ ] Payment page
### Backend Integration
- [ ] Replace MOCK_PRODUCTS with real API calls (lib/api.ts)
- [ ] Replace MOCK_USERS with real authentication
- [ ] Product images: replace placeholder with real images in public/imgs/products/
### Manager Page
- [ ] Create app/manager/page.tsx
- [ ] Menu management (add/edit/delete products)
- [ ] Order tracking dashboard
### UX Improvements
- [ ] Dark mode toggle (CSS variables already prepared in globals.css)
- [ ] Loading skeleton for product grid
- [ ] Toast notifications for cart actions
- [ ] Product detail modal/page
+22 -16
View File
@@ -20,27 +20,34 @@ import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
* Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4
*/
export default function Home() {
/* Start collapsed (false) so SSR and client initial render match.
* useEffect sets the correct value after hydration completes. */
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
/* Default: expanded on desktop, collapsed on mobile */
/* After mount: sync sidebar with viewport width, then subscribe to changes.
* The initial setIsSidebarOpen call is intentional (post-hydration only)
* and does not cause cascading renders — suppress the lint rule here. */
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsSidebarOpen(mq.matches);
const handler = (e: MediaQueryListEvent) => setIsSidebarOpen(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
/* Filter products by active category + search query */
/* Filter products by availability, active category, and search query.
* p.available defaults to true when undefined (opt-in unavailability). */
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory = activeCategory === "all" || p.category === activeCategory;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
return isAvailable && matchesCategory && matchesSearch;
});
/* Active category label */
@@ -59,8 +66,7 @@ export default function Home() {
return (
/* Outer wrapper: flex row, align-items: flex-start so sidebar sticks */
<div
className="flex items-start bg-[var(--color-bg-main)]"
style={{ minHeight: "calc(100vh - var(--spacing-header-height))" }}
className="flex items-start bg-background min-h-[calc(100vh-var(--spacing-header-height))]"
>
{/* ── Sidebar ── */}
<Navbar
@@ -80,10 +86,10 @@ export default function Home() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
{/* Title + count */}
<div className="shrink-0">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">
<h2 className="text-xl font-bold text-foreground">
{activeCategoryLabel}
</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
<p className="text-sm text-muted-foreground mt-0.5">
{filteredProducts.length} món
</p>
</div>
@@ -92,7 +98,7 @@ export default function Home() {
<div className="relative w-full sm:max-w-xs">
<i
className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2
text-sm text-[var(--color-text-muted)] pointer-events-none"
text-sm text-(--color-text-muted) pointer-events-none"
></i>
<input
type="text"
@@ -100,10 +106,10 @@ export default function Home() {
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tìm kiếm món..."
className="w-full pl-9 pr-9 py-2 text-sm rounded-xl border outline-none
bg-[var(--color-bg-card)] text-[var(--color-text-primary)]
border-[var(--color-border)] placeholder:text-[var(--color-text-muted)]
focus:border-[var(--color-primary)] focus:ring-2
focus:ring-[var(--color-primary)] focus:ring-opacity-20
bg-card text-foreground
border-border placeholder:text-muted-foreground
focus:border-primary focus:ring-2
focus:ring-primary focus:ring-opacity-20
transition-all duration-150"
/>
{/* Clear button */}
@@ -112,8 +118,8 @@ export default function Home() {
onClick={() => setSearchQuery("")}
title="Xóa tìm kiếm"
aria-label="Xóa tìm kiếm"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]
hover:text-[var(--color-primary)] transition-colors duration-150
className="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-text-muted)
hover:text-(--color-primary) transition-colors duration-150
cursor-pointer border-none bg-transparent p-0"
>
<i className="fa-solid fa-xmark text-sm"></i>
@@ -142,7 +148,7 @@ export default function Home() {
</div>
) : (
/* Empty state */
<div className="flex flex-col items-center justify-center py-24 gap-4 text-[var(--color-text-muted)]">
<div className="flex flex-col items-center justify-center py-24 gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
<p className="text-base font-medium">
{searchQuery
@@ -152,7 +158,7 @@ export default function Home() {
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-sm text-[var(--color-primary)] hover:underline cursor-pointer
className="text-sm text-(--color-primary) hover:underline cursor-pointer
border-none bg-transparent"
>
Xóa tìm kiếm
+136 -38
View File
@@ -1,60 +1,158 @@
# Components Documentation
> **BlackBox AI Rule:**
> Whenever you create a new component in the components/ directory, or when the user asks to save a component to this file, **automatically append a new section** following the template below. Fill in all fields based on the component source code — props/interface, default values, types, usage example, and notes. Do not skip any field; if a value is unknown, write N/A.
---
## Component Template (for BlackBox AI)
Use this template when adding a new component entry:
# Components Documentation
> Whenever you create a new component in the components/ directory, automatically append a new section following the template below.
---
## CartProduct
**File:** components/CartProduct.tsx
**Description:** A product card component that displays a product image, formatted price, a Mua (Buy) button, and a short description. Designed for use in shopping cart or product listing UIs.
**Description:** Product card component. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full), not the card itself.
### Props / Interface
### Props
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| image | string | yes | | URL or path to the product image (used with Next.js Image). |
| imageAlt | string | no | Product image | Alt text for the product image (accessibility). |
| price | number or string | yes | | Product price. If number, auto-formatted to Vietnamese Dong (VND). If string, rendered as-is. |
| description | string | yes | | Short product description shown in the card footer. Clamped to 2 lines. |
| onBuy | () => void | no | undefined | Callback fired when the Mua button is clicked. |
| image | string | yes | - | URL/path to product image (Next.js Image) |
| imageAlt | string | no | Anh san pham | Alt text for accessibility |
| productName | string | yes | - | Product display name |
| price | number or string | yes | - | If number: auto-formatted to VND. If string: rendered as-is |
| description | string | yes | - | Short description, clamped to 2 lines |
| onBuy | () => void | no | undefined | Callback when Buy button is clicked |
### Internal Logic
- formattedPrice: If price is a number, it is formatted using toLocaleString(vi-VN, { style: currency, currency: VND }). If price is already a string, it is rendered directly.
- formattedPrice: number -> toLocaleString(vi-VN, { style: currency, currency: VND })
- Image fallback: fa-solid fa-mug-hot icon shown behind image; if image fails onError hides the img element
### CSS / Styling Notes
### Styling (CSS variables)
| Element | Tailwind Classes | Purpose |
|---------|-----------------|---------|
| Card wrapper | flex flex-col w-64 rounded-2xl overflow-hidden shadow-md border border-gray-100 bg-white hover:shadow-lg transition-shadow duration-300 | Outer card with hover shadow |
| Image container | relative w-full h-48 bg-gray-100 | Fixed-height image area |
| Price text | text-lg font-bold text-red-500 | Highlighted price in red |
| Buy button | bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors duration-200 cursor-pointer | Primary action button |
| Description area | px-4 py-3 border-t border-gray-100 bg-gray-50 | Footer section with light background |
| Description text | text-sm text-gray-600 leading-relaxed line-clamp-2 | Truncated 2-line description |
| Element | Key classes |
|---------|-------------|
| Card wrapper | flex flex-col w-full rounded-2xl, shadow uses --color-shadow-sm/md |
| Image area | relative w-full h-36, bg --color-border-light |
| Product name | font-bold text-sm, color --color-text-primary, line-clamp-1 |
| Description | text-xs, color --color-text-muted, line-clamp-2 |
| Price | text-sm font-bold, color --color-primary |
| Buy button | bg --color-primary, hover --color-primary-dark, active:scale-95 |
### Dependencies
- next/image — Next.js optimized Image component
- Tailwind CSS — utility-first CSS framework
### Usage Example
- next/image
- Tailwind CSS + CSS custom properties from globals.css
- FontAwesome (fa-solid fa-mug-hot fallback, fa-cart-plus button icon)
### Notes
- Card width is fixed at w-64 (256px). Adjust if a responsive layout is needed.
- The description is clamped to 2 lines via line-clamp-2; longer text will be truncated with ellipsis.
- If onBuy is not provided, the button renders but does nothing on click.
- Price formatting uses Vietnamese locale (vi-VN) and VND currency by default when price is a number.
- Card width is w-full - controlled by parent grid in page.tsx
- available field on Product is checked in page.tsx before rendering
- onBuy currently logs to console - TODO: implement cart logic
---
## Navbar
**File:** components/Navbar.tsx
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header.
### Props
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| isOpen | boolean | yes | - | true = expanded (240px), false = collapsed (64px) |
| onToggle | () => void | yes | - | Toggle expand/collapse |
| activeCategory | string | no | all | Currently selected category id |
| onCategoryChange | (id: string) => void | no | undefined | Fired when user clicks a category |
### Behavior
- Collapsed: 64px wide, icon only (w-16)
- Expanded: 240px wide, icon + label (w-60)
- Width transition: transition-all duration-250ms
- Active category: highlighted with --color-primary background
- Footer shows SHOP_INFO.openHours (icon only when collapsed)
### Styling
| Element | Key classes |
|---------|-------------|
| Aside | sticky, border-r --color-border, bg --color-bg-sidebar |
| Toggle button | w-8 h-8 rounded-lg, hover bg --color-border-light |
| Active item | bg --color-primary text-white shadow-sm |
| Inactive item | hover bg --color-border-light, color --color-text-secondary |
### Dependencies
- next/link
- lib/constants: MENU_CATEGORIES, SHOP_INFO
- lib/types: MenuCategory
- FontAwesome icons
---
## Header (layouts/header.tsx)
**File:** layouts/header.tsx
**Description:** Sticky top bar. 2-column layout: Brand (left) + Auth button (right). Auth cycles Guest -> Manager -> Staff -> Guest for UI demo.
### Props
None - reads SHOP_INFO and MOCK_USERS from lib/constants directly.
### Internal State
| State | Type | Description |
|-------|------|-------------|
| user | User or null | Current demo user. null = guest |
### Auth States
| State | Appearance |
|-------|------------|
| Guest (null) | Brown primary button, Dang nhap label |
| Manager | Gold/caramel badge with user-tie icon |
| Staff | Avatar circle + name, bordered button |
### Responsive
- Logo + shop name: always visible
- Tagline: hidden < md, shown md+
- Button label: hidden < sm, shown sm+
### Dependencies
- next/image, next/link
- lib/constants: SHOP_INFO, MOCK_USERS
- lib/types: User
- FontAwesome icons
---
## Footer (layouts/footer.tsx)
**File:** layouts/footer.tsx
**Description:** Site footer with 12-column grid. 3 sections: Brand info, Social links, WiFi card.
### Props
None - reads SHOP_INFO and SOCIAL_LINKS from lib/constants directly.
### Layout
| Section | Mobile | md | lg/xl |
|---------|--------|----|-------|
| Brand info | col-span-12 | col-span-6 | col-span-8/6 |
| Social + WiFi | col-span-12 | col-span-6 | col-span-4/6 |
### Sections
1. Brand: logo, name, tagline, address, phone, email, open hours
2. Social: Facebook, TikTok, Website links
3. WiFi: network name + password in monospace styled box
4. Bottom bar: copyright + Made with heart in Vietnam
### Dependencies
- next/image, next/link
- lib/constants: SHOP_INFO, SOCIAL_LINKS
- FontAwesome icons
+11 -11
View File
@@ -37,16 +37,16 @@ export default function CartProduct({
return (
<div
className="flex flex-col w-full rounded-2xl overflow-hidden
bg-[var(--color-bg-card)] border border-[var(--color-border-light)]
bg-(--color-bg-card) border border-(--color-border-light)
shadow-[0_2px_8px_var(--color-shadow-sm)]
hover:shadow-[0_6px_20px_var(--color-shadow-md)]
hover:-translate-y-0.5
transition-all duration-[250ms] cursor-default"
transition-all duration-250 cursor-default"
>
{/* ── Image area ── */}
<div className="relative w-full h-36 bg-[var(--color-border-light)] shrink-0 overflow-hidden">
<div className="relative w-full h-36 bg-(--color-border-light) shrink-0 overflow-hidden">
{/* Fallback icon (shown when image fails or is missing) */}
<div className="absolute inset-0 flex items-center justify-center text-4xl text-[var(--color-border)] z-0">
<div className="absolute inset-0 flex items-center justify-center text-4xl text-(--color-border) z-0">
<i className="fa-solid fa-mug-hot"></i>
</div>
{/* Product image */}
@@ -54,7 +54,7 @@ export default function CartProduct({
src={image}
alt={imageAlt}
fill
className="object-cover z-[1]"
className="object-cover z-1"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
@@ -64,10 +64,10 @@ export default function CartProduct({
{/* ── Name + description ── */}
<div className="flex-1 flex flex-col gap-1 px-3 pt-2.5 pb-1.5">
<h3 className="font-bold text-sm leading-tight text-[var(--color-text-primary)] line-clamp-1">
<h3 className="font-bold text-sm leading-tight text-foreground line-clamp-1">
{productName}
</h3>
<p className="text-xs leading-relaxed text-[var(--color-text-muted)] line-clamp-2">
<p className="text-xs leading-relaxed text-(--color-text-muted) line-clamp-2">
{description}
</p>
</div>
@@ -75,17 +75,17 @@ export default function CartProduct({
{/* ── Price + Buy button ── */}
<div
className="flex items-center justify-between px-3 py-2.5
border-t border-[var(--color-border-light)] shrink-0"
border-t border-(--color-border-light) shrink-0"
>
<span className="text-sm font-bold text-[var(--color-primary)]">
<span className="text-sm font-bold text-(--color-primary)">
{formattedPrice}
</span>
<button
onClick={onBuy}
className="flex items-center gap-1.5 text-xs font-semibold
px-3 py-1.5 rounded-lg border-none cursor-pointer
bg-[var(--color-primary)] text-white
hover:bg-[var(--color-primary-dark)]
bg-(--color-primary) text-white
hover:bg-(--color-primary-dark)
active:scale-95
transition-all duration-150 whitespace-nowrap"
>
+18 -23
View File
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
@@ -30,35 +29,31 @@ export default function Navbar({
activeCategory = "all",
onCategoryChange,
}: NavbarProps) {
const handleClick = (id: string) => {
onCategoryChange?.(id);
};
return (
<aside
className={`
sticky shrink-0 flex flex-col z-20
border-r border-[var(--color-border)]
bg-[var(--color-bg-sidebar)]
border-r border-(--color-border)
bg-(--color-bg-sidebar)
overflow-y-auto overflow-x-hidden
transition-all duration-[250ms] ease-in-out
transition-all duration-250 ease-in-out
${isOpen ? "w-60" : "w-16"}
`}
style={{
top: "var(--spacing-header-height)",
height: "calc(100vh - var(--spacing-header-height))",
}}
} as React.CSSProperties}
>
{/* ── Sidebar header: title + toggle button ── */}
<div
className={`
flex items-center border-b border-[var(--color-border)] shrink-0
flex items-center border-b border-(--color-border) shrink-0
${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"}
`}
>
{isOpen && (
<span className="text-xs font-bold uppercase tracking-widest text-[var(--color-text-muted)] whitespace-nowrap">
<i className="fa-solid fa-utensils mr-2 text-[var(--color-primary)]"></i>
<span className="text-xs font-bold uppercase tracking-widest text-(--color-text-muted) whitespace-nowrap">
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
Thực Đơn
</span>
)}
@@ -66,12 +61,12 @@ export default function Navbar({
onClick={onToggle}
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
text-[var(--color-text-muted)] bg-transparent
hover:bg-[var(--color-border-light)] hover:text-[var(--color-primary)]
text-(--color-text-muted) bg-transparent
hover:bg-(--color-border-light) hover:text-(--color-primary)
transition-colors duration-150 shrink-0"
>
<i
className={`fa-solid text-sm transition-transform duration-[250ms] ${
className={`fa-solid text-sm transition-transform duration-250 ${
isOpen ? "fa-chevron-left" : "fa-chevron-right"
}`}
></i>
@@ -86,7 +81,7 @@ export default function Navbar({
return (
<li key={cat.id}>
<button
onClick={() => handleClick(cat.id)}
onClick={() => onCategoryChange?.(cat.id)}
title={!isOpen ? cat.name : undefined}
className={`
w-full flex items-center rounded-xl text-sm font-medium
@@ -94,8 +89,8 @@ export default function Navbar({
${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"}
${
isActive
? "bg-[var(--color-primary)] text-white shadow-sm"
: "bg-transparent text-[var(--color-text-secondary)] hover:bg-[var(--color-border-light)] hover:text-[var(--color-primary-dark)]"
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
}
`}
>
@@ -103,7 +98,7 @@ export default function Navbar({
<i
className={`
${cat.icon} w-5 text-center text-base shrink-0
${isActive ? "text-white" : "text-[var(--color-primary)]"}
${isActive ? "text-white" : "text-(--color-primary)"}
`}
></i>
@@ -128,18 +123,18 @@ export default function Navbar({
{/* ── Sidebar footer: opening hours ── */}
<div
className={`
shrink-0 border-t border-[var(--color-border)] py-3
shrink-0 border-t border-(--color-border) py-3
${isOpen ? "px-4" : "px-0 flex justify-center"}
`}
>
{isOpen ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<i className="fa-solid fa-clock text-[var(--color-accent)] shrink-0"></i>
<div className="flex items-center gap-2 text-xs text-(--color-text-muted)">
<i className="fa-solid fa-clock text-(--color-accent) shrink-0"></i>
<span>{SHOP_INFO.openHours}</span>
</div>
) : (
<i
className="fa-solid fa-clock text-sm text-[var(--color-text-muted)]"
className="fa-solid fa-clock text-sm text-(--color-text-muted)"
title={SHOP_INFO.openHours}
></i>
)}
+22 -22
View File
@@ -17,7 +17,7 @@ import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
*/
export default function Footer() {
return (
<footer className="w-full bg-[var(--color-bg-footer)] text-[var(--color-text-on-dark)] overflow-x-hidden">
<footer className="w-full bg-(--color-bg-footer) text-(--color-text-on-dark) overflow-x-hidden">
{/* ── Main grid ── */}
<div className="max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8 py-10">
<div className="grid grid-cols-12 gap-x-8 gap-y-8">
@@ -35,7 +35,7 @@ export default function Footer() {
sizes="40px"
/>
</div>
<span className="font-bold text-lg text-[var(--color-accent)]">
<span className="font-bold text-lg text-(--color-accent)">
{SHOP_INFO.name}
</span>
</div>
@@ -48,29 +48,29 @@ export default function Footer() {
{/* Contact details */}
<ul className="flex flex-col gap-2 text-sm opacity-80">
<li className="flex items-start gap-2">
<i className="fa-solid fa-location-dot mt-0.5 w-4 text-center text-[var(--color-accent)] shrink-0"></i>
<i className="fa-solid fa-location-dot mt-0.5 w-4 text-center text-(--color-accent) shrink-0"></i>
<span>Đa chỉ: {SHOP_INFO.address}</span>
</li>
<li className="flex items-center gap-2">
<i className="fa-solid fa-phone w-4 text-center text-[var(--color-accent)] shrink-0"></i>
<i className="fa-solid fa-phone w-4 text-center text-(--color-accent) shrink-0"></i>
<a
href={`tel:${SHOP_INFO.phone}`}
className="hover:text-[var(--color-accent)] transition-colors duration-150"
className="hover:text-(--color-accent) transition-colors duration-150"
>
Số điện thoại: {SHOP_INFO.phone}
</a>
</li>
<li className="flex items-center gap-2">
<i className="fa-solid fa-envelope w-4 text-center text-[var(--color-accent)] shrink-0"></i>
<i className="fa-solid fa-envelope w-4 text-center text-(--color-accent) shrink-0"></i>
<a
href={`mailto:${SHOP_INFO.email}`}
className="hover:text-[var(--color-accent)] transition-colors duration-150"
className="hover:text-(--color-accent) transition-colors duration-150"
>
Email: {SHOP_INFO.email}
</a>
</li>
<li className="flex items-center gap-2">
<i className="fa-solid fa-clock w-4 text-center text-[var(--color-accent)] shrink-0"></i>
<i className="fa-solid fa-clock w-4 text-center text-(--color-accent) shrink-0"></i>
<span>Open: {SHOP_INFO.openHours}</span>
</li>
</ul>
@@ -84,7 +84,7 @@ export default function Footer() {
{/* ── 2. Social links ── */}
<div className="col-span-1">
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-[var(--color-accent)]">
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-(--color-accent)">
Kết nối
</h3>
<ul className="flex flex-col gap-3">
@@ -94,7 +94,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-[var(--color-accent)] transition-all duration-150"
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
@@ -111,7 +111,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-[var(--color-accent)] transition-all duration-150"
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
@@ -126,11 +126,11 @@ export default function Footer() {
<Link
href={SOCIAL_LINKS.website}
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-[var(--color-accent)] transition-all duration-150"
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
bg-[var(--color-primary)] text-white text-base"
bg-(--color-primary) text-white text-base"
>
<i className="fa-solid fa-globe"></i>
</span>
@@ -142,15 +142,15 @@ export default function Footer() {
{/* ── 3. WiFi card ── */}
<div className="col-span-1">
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-[var(--color-accent)]">
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-(--color-accent)">
WiFi Miễn Phí
</h3>
<div
className="rounded-xl border border-[var(--color-primary-light)] border-opacity-50
p-4 bg-[var(--color-primary-dark)] bg-opacity-30"
className="rounded-xl border border-(--color-primary-light) border-opacity-50
p-4 bg-(--color-primary-dark) bg-opacity-30"
>
<div className="flex items-center gap-2 mb-3">
<i className="fa-solid fa-wifi text-[var(--color-accent)] text-lg shrink-0"></i>
<i className="fa-solid fa-wifi text-(--color-accent) text-lg shrink-0"></i>
<span className="font-semibold text-sm">Kết nối miễn phí</span>
</div>
{/* Stacked label + value rows — no overflow risk */}
@@ -158,8 +158,8 @@ export default function Footer() {
<div className="flex flex-col gap-1">
<span className="opacity-60 text-xs uppercase tracking-wide">Tên mạng</span>
<span
className="font-mono font-bold text-[var(--color-accent)]
px-2 py-1 rounded border border-[var(--color-accent)]
className="font-mono font-bold text-(--color-accent)
px-2 py-1 rounded border border-(--color-accent)
border-opacity-30 break-all"
>
{SHOP_INFO.wifi.name}
@@ -168,8 +168,8 @@ export default function Footer() {
<div className="flex flex-col gap-1">
<span className="opacity-60 text-xs uppercase tracking-wide">Mật khẩu</span>
<span
className="font-mono font-bold text-[var(--color-accent)] tracking-wider
px-2 py-1 rounded border border-[var(--color-accent)]
className="font-mono font-bold text-(--color-accent) tracking-wider
px-2 py-1 rounded border border-(--color-accent)
border-opacity-30 break-all"
>
{SHOP_INFO.wifi.password}
@@ -196,7 +196,7 @@ export default function Footer() {
</span>
<span className="flex items-center gap-1">
Made with{" "}
<i className="fa-solid fa-heart text-[var(--color-accent)] mx-1"></i>{" "}
<i className="fa-solid fa-heart text-(--color-accent) mx-1"></i>{" "}
in Vietnam
</span>
</div>
+21 -22
View File
@@ -9,17 +9,16 @@ import type { User } from "@/lib/types";
/**
* Site Header — sticky top bar, always visible on all screen sizes.
*
* 3-column layout:
* [Brand: logo + name + tagline] | [Center: quick info] | [Auth button]
* 2-column layout:
* [LEFT: Brand (logo + name + tagline)] | [RIGHT: Auth button]
*
* Login state cycles for UI demo (click the button to switch):
* Auth state cycles for UI demo (click the button to switch):
* Guest → Manager (Quản lý) → Staff → Guest
*
* Responsive:
* - Logo + shop name : always visible
* - Tagline : hidden on mobile, shown on md+
* - Center info : hidden on mobile, shown on lg+
* - Button label : hidden on xs, shown on sm+
* - Tagline : hidden on mobile (< md), shown on md+
* - Button label : hidden on xs (< sm), shown on sm+
*/
export default function Header() {
const [user, setUser] = useState<User | null>(null);
@@ -33,9 +32,9 @@ export default function Header() {
return (
<header
className="w-full sticky top-0 z-50
bg-[var(--color-bg-header)] border-b border-[var(--color-border)]
shadow-[0_1px_8px_var(--color-shadow-sm)]"
style={{ height: "var(--spacing-header-height)" }}
bg-(--color-bg-header) border-b border-(--color-border)
shadow-[0_1px_8px_var(--color-shadow-sm)]
h-(--spacing-header-height)"
>
<div
className="h-full px-6 md:px-8 lg:px-12
@@ -64,13 +63,13 @@ export default function Header() {
<div className="flex flex-col leading-tight">
<span
className="font-bold text-base md:text-lg
text-[var(--color-primary-dark)]
group-hover:text-[var(--color-primary)]
text-(--color-primary-dark)
group-hover:text-(--color-primary)
transition-colors duration-150"
>
{SHOP_INFO.name}
</span>
<span className="text-xs hidden md:block text-[var(--color-text-muted)]">
<span className="text-xs hidden md:block text-(--color-text-muted)">
{SHOP_INFO.tagline}
</span>
</div>
@@ -86,8 +85,8 @@ export default function Header() {
title="Đăng nhập"
className="flex items-center gap-2.5 px-5 py-2.5 rounded-xl
text-sm font-semibold border-none cursor-pointer
bg-[var(--color-primary)] text-white
hover:bg-[var(--color-primary-dark)]
bg-(--color-primary) text-white
hover:bg-(--color-primary-dark)
active:scale-95 transition-all duration-150"
>
<i className="fa-solid fa-right-to-bracket"></i>
@@ -101,9 +100,9 @@ export default function Header() {
title="Nhấn để đổi tài khoản (demo)"
className="flex items-center gap-2.5 px-4 py-2.5 rounded-xl
text-sm font-semibold cursor-pointer
bg-[var(--color-accent-light)] border border-[var(--color-accent)]
text-[var(--color-primary-dark)]
hover:bg-[var(--color-accent)] hover:text-white
bg-(--color-accent-light) border border-(--color-accent)
text-(--color-primary-dark)
hover:bg-(--color-accent) hover:text-white
active:scale-95 transition-all duration-150"
>
<i className="fa-solid fa-user-tie text-base"></i>
@@ -117,16 +116,16 @@ export default function Header() {
title="Nhấn để đổi tài khoản (demo)"
className="flex items-center gap-2.5 px-4 py-2 rounded-xl
text-sm font-semibold cursor-pointer
bg-[var(--color-bg-main)] border border-[var(--color-border)]
text-[var(--color-text-secondary)]
hover:border-[var(--color-primary-light)]
hover:bg-[var(--color-border-light)]
bg-background border border-(--color-border)
text-(--color-text-secondary)
hover:border-(--color-primary-light)
hover:bg-(--color-border-light)
active:scale-95 transition-all duration-150"
>
{/* Avatar circle */}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0
bg-[var(--color-primary-light)] text-white text-xs"
bg-(--color-primary-light) text-white text-xs"
>
<i className="fa-solid fa-user"></i>
</div>
+10 -9
View File
@@ -1,4 +1,4 @@
import type { MenuCategory, Product, ShopInfo, SocialLinks, NavLink, User } from "./types";
import type { MenuCategory, Product, ShopInfo, SocialLinks, User } from "./types";
// ===== SHOP INFORMATION =====
export const SHOP_INFO: ShopInfo = {
@@ -23,14 +23,6 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/",
};
// ===== NAVIGATION LINKS =====
export const NAV_LINKS: NavLink[] = [
{ label: "Trang chủ", href: "/", icon: "fa-solid fa-house" },
{ label: "Thực đơn", href: "/menu", icon: "fa-solid fa-book-open" },
{ label: "Về chúng tôi", href: "/about", icon: "fa-solid fa-circle-info" },
{ label: "Liên hệ", href: "/contact", icon: "fa-solid fa-envelope" },
];
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
@@ -200,6 +192,15 @@ export const MOCK_PRODUCTS: Product[] = [
description: "Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
available: true,
},
{
id: 18,
name: "Trân Châu Trắng",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description: "Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
available: true,
},
];
// ===== MOCK USERS (for UI demo replace with real auth) =====
+1 -7
View File
@@ -44,13 +44,7 @@ export interface ShopInfo {
openHours: string;
}
// ===== NAVIGATION TYPES =====
export interface NavLink {
label: string;
href: string;
icon?: string;
}
// ===== SOCIAL LINKS TYPES =====
export interface SocialLinks {
facebook: string;
tiktok: string;
+10 -3
View File
@@ -1,7 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
/**
* Next.js configuration.
*
* Currently uses all defaults — no custom rewrites, redirects, or image domains needed.
* Add options here as the project grows (e.g. `images.remotePatterns` when using
* external image URLs, `experimental.serverActions` for form handling, etc.)
*
* Docs: https://nextjs.org/docs/app/api-reference/next-config-js
*/
const nextConfig: NextConfig = {};
export default nextConfig;
+1
View File
@@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"forceConsistentCasingInFileNames": true,
"plugins": [
{
"name": "next"