Merge pull request #1 from ThanhQuy-wolf/Created_Page_User

Created page user
This commit is contained in:
ThanhQuy-wolf
2026-03-23 10:05:58 +07:00
committed by GitHub
33 changed files with 9655 additions and 105 deletions
+93 -21
View File
@@ -1,36 +1,108 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Coffee Shop Frontend
## Getting Started
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.
First, run the development server:
---
## Mô Tả Dự Án
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:
- 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 - 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
---
## Cách Chạy Dự Án
### Yêu cầu hệ thống
- Node.js >= 18
- pnpm (khuyến nghị) hoặc npm
### Cài đặt
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
pnpm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Dev
```bash
pnpm dev
```
Mở trình duyệt tại http://localhost:3000
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### Build
```bash
pnpm build && pnpm start
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
### Lint
```bash
pnpm lint
```
## Learn More
---
To learn more about Next.js, take a look at the following resources:
## Cấu Trúc Thư Mục
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```
frondend/
+-- 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
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
---
## Deploy on Vercel
## Công Nghệ Sử Dụng
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
| Công nghệ | Phiên bản | Mục đích |
|-----------|-----------|----------|
| Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4 | Utility-first CSS framework |
| Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager |
| ESLint | ^9 | Linting |
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
---
## Ghi Chú Phát Triển
- 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
View File
@@ -0,0 +1,52 @@
# 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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+101 -13
View File
@@ -1,26 +1,114 @@
@import "tailwindcss";
/* ============================================================
LIGHT THEME — Coffee Brown & Beige Palette
All color/spacing/radius tokens live here so switching to
dark theme later only requires overriding these variables
in the [data-theme="dark"] block below.
============================================================ */
:root {
--background: #ffffff;
--foreground: #171717;
/* --- Brand Colors --- */
--color-primary: #6F4E37; /* coffee brown */
--color-primary-light: #A0785A; /* lighter brown */
--color-primary-dark: #4A3728; /* dark espresso */
--color-accent: #C8973A; /* golden caramel */
--color-accent-light: #F0D9A8; /* pale caramel */
/* --- Background Colors --- */
--color-bg-main: #FDF6EC; /* warm beige */
--color-bg-card: #FFFFFF;
--color-bg-sidebar: #FFFFFF;
--color-bg-header: #FFFFFF;
--color-bg-footer: #3D2B1F; /* dark espresso */
/* --- Text Colors --- */
--color-text-primary: #2C1A0E; /* very dark brown */
--color-text-secondary: #6F4E37; /* coffee brown */
--color-text-muted: #A08060; /* warm grey-brown */
--color-text-on-dark: #FDF6EC; /* beige on dark bg */
--color-text-on-primary: #FFFFFF; /* white on primary */
/* --- Border & Divider --- */
--color-border: #E2C9A8; /* warm tan */
--color-border-light: #F0E4D0; /* very light tan */
/* --- Shadow --- */
--color-shadow-sm: rgba(111, 78, 55, 0.08);
--color-shadow-md: rgba(111, 78, 55, 0.18);
/* --- Layout Dimensions --- */
--spacing-header-height: 72px;
--spacing-sidebar-width: 240px; /* expanded */
--spacing-sidebar-collapsed: 64px; /* icon-only */
--spacing-content-max: 1536px;
/* --- Border Radius --- */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* --- Transitions --- */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
}
/* ============================================================
DARK THEME PLACEHOLDER
Uncomment & fill values when dark mode is needed.
============================================================ */
/*
[data-theme="dark"] {
--color-primary: #A0785A;
--color-primary-light: #C8956C;
--color-primary-dark: #6F4E37;
--color-accent: #D4A96A;
--color-accent-light: #7A5C30;
--color-bg-main: #1A0F08;
--color-bg-card: #2D1B0E;
--color-bg-sidebar: #231408;
--color-bg-header: #1A0F08;
--color-bg-footer: #0D0704;
--color-text-primary: #FDF6EC;
--color-text-secondary: #C8956C;
--color-text-muted: #8B7060;
--color-text-on-dark: #FDF6EC;
--color-border: #4A2E1A;
--color-border-light: #3D2314;
}
*/
/* ============================================================
TAILWIND THEME BRIDGE
============================================================ */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-background: var(--color-bg-main);
--color-foreground: var(--color-text-primary);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
/* ============================================================
BASE STYLES
============================================================ */
body {
background-color: var(--color-bg-main);
color: var(--color-text-primary);
font-family: Arial, Helvetica, sans-serif;
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
/* ============================================================
CUSTOM SCROLLBAR
============================================================ */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--color-bg-main); }
::-webkit-scrollbar-thumb {
background: var(--color-primary-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover { background: var(--color-primary); }
+33 -5
View File
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/layouts/header";
import Footer from "@/layouts/footer";
import { Providers } from "./providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +16,13 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Coffee Shop — Hệ thống đặt món",
description: "Đặt món cà phê, trà, nước ép và nhiều hơn nữa tại Coffee Shop.",
icons: {
icon: "/favicon/favicon.ico",
shortcut: "/favicon/favicon.ico",
apple: "/favicon/apple-touch-icon.png",
},
};
export default function RootLayout({
@@ -23,11 +31,31 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="vi">
<head>
{/* FontAwesome 6 — icons used throughout the app */}
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
>
{children}
<Providers>
{/* Sticky top header */}
<Header />
{/* Page content (grows to fill remaining height) */}
<div className="flex-1">
{children}
</div>
{/* Footer always at bottom */}
<Footer />
</Providers>
</body>
</html>
);
+205 -56
View File
@@ -1,64 +1,213 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import Navbar from "@/components/Navbar";
import CartProduct from "@/components/CartProduct";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
/**
* Main page — sidebar + product grid layout.
*
* Layout:
* [Sidebar (sticky, collapsible)] | [Main content (scrollable)]
*
* Sidebar state:
* - Desktop (≥ 1024px): expanded by default
* - Mobile (< 1024px): collapsed by default
*
* Product grid columns (responsive, depends on sidebar state):
* Collapsed sidebar: 2 → sm:2 → lg:3 → xl:4 → 2xl:5
* Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4
*/
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.
* useEffect sets the correct value after hydration completes. */
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
/* 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);
}, []);
/* 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.
* 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 isAvailable && matchesCategory && matchesSearch;
});
/* Active category label */
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
/* Responsive grid class based on sidebar state
* Base (< 480px) : 1 col — very small phones
* min-[480px] : 2 cols — larger phones
* lg+ : 2/3 cols depending on sidebar
*/
const gridCols = isSidebarOpen
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/* Outer wrapper: flex row, align-items: flex-start so sidebar sticks */
<div
className="flex items-start bg-background min-h-[calc(100vh-var(--spacing-header-height))]"
>
{/* ── Sidebar ── */}
<Navbar
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
{/* ── Main content ── */}
<main className="flex-1 min-w-0 px-4 py-6 md:px-6 lg:px-8">
{/* ── Section heading + search bar ── */}
<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-foreground">
{activeCategoryLabel}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{filteredProducts.length} món
</p>
</div>
{/* Search input */}
<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-(--color-text-muted) pointer-events-none"
></i>
<input
type="text"
value={searchQuery}
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-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"
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* Clear button */}
{searchQuery && (
<button
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-(--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>
</button>
)}
</div>
</div>
{/* ── Mobile category menu — visible only on < md, below search, above products ── */}
<div className="md:hidden sticky top-18 pt-2 bg-background z-50 -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 ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<CartProduct
key={product.id}
image={product.image}
imageAlt={product.name}
productName={product.name}
price={product.price}
description={product.description}
onBuy={() => {
/* TODO: add to cart logic */
console.log("Thêm vào giỏ:", product.name);
}}
/>
))}
</div>
) : (
/* Empty state */
<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
? `Không tìm thấy món nào cho "${searchQuery}"`
: "Chưa có món trong danh mục này"}
</p>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-sm text-(--color-primary) hover:underline cursor-pointer
border-none bg-transparent"
>
Xóa tìm kiếm
</button>
)}
</div>
)}
</main>
</div>
);
+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>;
}
+158
View File
@@ -0,0 +1,158 @@
# 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:** 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
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| 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: 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
### Styling (CSS variables)
| 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
- Tailwind CSS + CSS custom properties from globals.css
- FontAwesome (fa-solid fa-mug-hot fallback, fa-cart-plus button icon)
### Notes
- 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
+98
View File
@@ -0,0 +1,98 @@
"use client";
import Image from "next/image";
interface CartProductProps {
image: string;
imageAlt?: string;
productName: string;
price: number | string;
description: string;
onBuy?: () => void;
}
/**
* Product card — fills the parent grid cell width (w-full).
*
* Layout (top → bottom):
* 1. Image area (fixed height h-36) with coffee-mug fallback icon
* 2. Name + description (flex-1, grows to fill space)
* 3. Price + Buy button row (pinned to bottom)
*
* Responsive: card width is controlled by the parent grid, not the card itself.
*/
export default function CartProduct({
image,
imageAlt = "Ảnh sản phẩm",
productName,
price,
description,
onBuy,
}: CartProductProps) {
const formattedPrice =
typeof price === "number"
? price.toLocaleString("vi-VN", { style: "currency", currency: "VND" })
: price;
return (
<div
className="flex flex-col w-full rounded-2xl overflow-hidden
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-250 cursor-default"
>
{/* ── Image area ── */}
<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-(--color-border) z-0">
<i className="fa-solid fa-mug-hot"></i>
</div>
{/* Product image */}
<Image
src={image}
alt={imageAlt}
fill
className="object-cover z-1"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
</div>
{/* ── 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-foreground line-clamp-1">
{productName}
</h3>
<p className="text-xs leading-relaxed text-(--color-text-muted) line-clamp-2">
{description}
</p>
</div>
{/* ── Price + Buy button ── */}
<div
className="flex items-center justify-between px-3 py-2.5
border-t border-(--color-border-light) shrink-0"
>
<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-(--color-primary) text-white
hover:bg-(--color-primary-dark)
active:scale-95
transition-all duration-150 whitespace-nowrap"
>
<i className="fa-solid fa-cart-plus"></i>
Mua
</button>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
interface NavbarProps {
/** Whether the sidebar is expanded (true) or icon-only (false) */
isOpen: boolean;
/** Toggle expand / collapse */
onToggle: () => void;
/** Currently selected category id */
activeCategory?: string;
/** Fired when user clicks a category */
onCategoryChange?: (id: string) => void;
}
/**
* Left sidebar — always visible, collapsible on all screen sizes.
*
* Collapsed → 64 px wide, icon only
* Expanded → 240 px wide, icon + label
*
* Width transition is handled by Tailwind w-16 / w-60 + transition-all.
* Parent controls open/close state via isOpen + onToggle props.
*/
export default function Navbar({
isOpen,
onToggle,
activeCategory = "all",
onCategoryChange,
}: NavbarProps) {
return (
<aside
className={`
sticky shrink-0 flex-col z-20
border-r border-(--color-border)
bg-(--color-bg-sidebar)
overflow-y-auto overflow-x-hidden
transition-all duration-250 ease-in-out
hidden md:flex
xl:w-60
${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-(--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"}
`}
>
{/* Title — shown when expanded, always shown on xl+ */}
<span
className={`
text-xs font-bold uppercase tracking-widest
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
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-(--color-text-muted) bg-transparent
hover:bg-(--color-border-light) hover:text-(--color-primary)
transition-colors duration-150 shrink-0 xl:hidden"
>
<i
className={`fa-solid text-sm transition-transform duration-250 ${
isOpen ? "fa-chevron-left" : "fa-chevron-right"
}`}
></i>
</button>
</div>
{/* ── Category list ── */}
<nav className="flex-1 py-2">
<ul className="flex flex-col gap-0.5 px-2">
{MENU_CATEGORIES.map((cat: MenuCategory) => {
const isActive = activeCategory === cat.id;
return (
<li key={cat.id}>
<button
onClick={() => onCategoryChange?.(cat.id)}
title={!isOpen ? cat.name : undefined}
className={`
w-full flex items-center rounded-xl text-sm font-medium
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"}
${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
}
`}
>
{/* Icon */}
<i
className={`
${cat.icon} w-5 text-center text-base shrink-0
${isActive ? "text-white" : "text-(--color-primary)"}
`}
></i>
{/* Label — hidden when collapsed, always shown on xl+ */}
<span
className={`
whitespace-nowrap overflow-hidden text-ellipsis
${isOpen ? "block" : "hidden"} xl:block
`}
>
{cat.name}
</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* ── Sidebar footer: opening hours ── */}
<div
className={`
shrink-0 border-t border-(--color-border) py-3
xl:px-4
${isOpen ? "px-4" : "px-0 flex justify-center"}
`}
>
{/* Text row — shown when expanded, always shown on xl+ */}
<div
className={`
items-center gap-2 text-xs text-(--color-text-muted)
${isOpen ? "flex" : "hidden"} xl:flex
`}
>
<i className="fa-solid fa-clock text-(--color-accent) shrink-0"></i>
<span>{SHOP_INFO.openHours}</span>
</div>
{/* Icon-only — shown when collapsed, hidden on xl+ */}
<span className="xl:hidden">
<i
className={`
fa-solid fa-clock text-sm text-(--color-text-muted)
${isOpen ? "hidden" : "block"}`}
title={SHOP_INFO.openHours}
></i>
</span>
</div>
</aside>
);
}
+206
View File
@@ -0,0 +1,206 @@
import Image from "next/image";
import Link from "next/link";
import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
/**
* Site Footer — 3-section 12-column grid.
*
* Sections:
* 1. Brand info (logo, name, tagline, address, phone, hours)
* 2. Social links (Facebook, TikTok, Website)
* 3. WiFi card (network name + password)
*
* Responsive:
* - Mobile : all sections stack full-width (col-span-12)
* - md : brand 6 cols | social 3 | wifi 3
* - lg : brand 5 cols | social 3 | wifi 4
*/
export default function Footer() {
return (
<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">
{/* ── 1. Brand info ── */}
<div className="col-span-12 md:col-span-6 lg:col-span-8 xl:col-span-6">
{/* Logo + name */}
<div className="flex items-center gap-3 mb-3">
<div className="relative w-10 h-10 shrink-0">
<Image
src={SHOP_INFO.logo}
alt={`Logo ${SHOP_INFO.name}`}
fill
className="object-contain"
sizes="40px"
/>
</div>
<span className="font-bold text-lg text-(--color-accent)">
{SHOP_INFO.name}
</span>
</div>
{/* Tagline */}
<p className="text-sm opacity-75 leading-relaxed mb-4">
{SHOP_INFO.tagline}
</p>
{/* 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-(--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-(--color-accent) shrink-0"></i>
<a
href={`tel:${SHOP_INFO.phone}`}
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-(--color-accent) shrink-0"></i>
<a
href={`mailto:${SHOP_INFO.email}`}
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-(--color-accent) shrink-0"></i>
<span>Open: {SHOP_INFO.openHours}</span>
</li>
</ul>
</div>
{/* ── Right column: Social + WiFi
md : side-by-side (each half of the 6-col right block)
lg : stacked (WiFi below Social, both full width of the 4-col block)
── */}
<div className="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-6 grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* ── 2. Social links ── */}
<div className="col-span-1">
<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">
<li>
<a
href={SOCIAL_LINKS.facebook}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
bg-[#1877F2] text-white text-base"
>
<i className="fa-brands fa-facebook-f"></i>
</span>
Facebook
</a>
</li>
<li>
<a
href={SOCIAL_LINKS.tiktok}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
bg-black text-white text-base"
>
<i className="fa-brands fa-tiktok"></i>
</span>
TikTok
</a>
</li>
<li>
<Link
href={SOCIAL_LINKS.website}
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
hover:text-(--color-accent) transition-all duration-150"
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
bg-(--color-primary) text-white text-base"
>
<i className="fa-solid fa-globe"></i>
</span>
Website
</Link>
</li>
</ul>
</div>
{/* ── 3. WiFi card ── */}
<div className="col-span-1">
<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-(--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-(--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 */}
<div className="flex flex-col gap-3 text-sm">
<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-(--color-accent)
px-2 py-1 rounded border border-(--color-accent)
border-opacity-30 break-all"
>
{SHOP_INFO.wifi.name}
</span>
</div>
<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-(--color-accent) tracking-wider
px-2 py-1 rounded border border-(--color-accent)
border-opacity-30 break-all"
>
{SHOP_INFO.wifi.password}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* ── Bottom bar ── */}
<div className="border-t border-white border-opacity-10">
<div
className="max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8 py-4
flex flex-col sm:flex-row items-center justify-between gap-2
text-xs opacity-50"
>
<span>
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
</span>
<span className="flex items-center gap-1">
Đưc vận hành {" "}
<i className="fa-solid fa-heart text-(--color-accent) mx-1"></i>{" "}
bằng Drinkool
</span>
</div>
</div>
</footer>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { SHOP_INFO, MOCK_USERS } from "@/lib/constants";
import type { User } from "@/lib/types";
/**
* Site Header — sticky top bar, always visible on all screen sizes.
*
* 2-column layout:
* [LEFT: Brand (logo + name + tagline)] | [RIGHT: Auth button]
*
* 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 (< md), shown on md+
* - Button label : hidden on xs (< sm), shown on sm+
*/
export default function Header() {
const [user, setUser] = useState<User | null>(null);
const cycleUser = () => {
if (!user) setUser(MOCK_USERS.manager);
else if (user.role === "manager") setUser(MOCK_USERS.staff);
else setUser(null);
};
return (
<header
className="w-full sticky top-0 z-50
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
flex items-center justify-between gap-6
max-w-screen-2xl mx-auto"
>
{/* ── LEFT: Brand ── */}
<Link
href="/"
className="flex items-center gap-4 shrink-0 no-underline group"
>
{/* Logo */}
<div className="relative w-10 h-10 md:w-11 md:h-11 shrink-0">
<Image
src={SHOP_INFO.logo}
alt={`Logo ${SHOP_INFO.name}`}
fill
className="object-contain transition-transform duration-200 group-hover:scale-105"
sizes="44px"
priority
/>
</div>
{/* Name + tagline */}
<div className="flex flex-col leading-tight">
<span
className="font-bold text-base md:text-lg
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-(--color-text-muted)">
{SHOP_INFO.tagline}
</span>
</div>
</Link>
{/* ── RIGHT: Auth ── */}
<div className="flex items-center gap-3 shrink-0">
{!user ? (
/* Guest: sign-in button */
<button
onClick={cycleUser}
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-(--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>
<span className="hidden sm:inline">Đăng nhập</span>
</button>
) : user.role === "manager" ? (
/* Manager: gold badge */
<button
onClick={cycleUser}
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-(--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>
<span className="hidden sm:inline">Quản </span>
</button>
) : (
/* Staff / regular user: avatar + name */
<button
onClick={cycleUser}
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-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-(--color-primary-light) text-white text-xs"
>
<i className="fa-solid fa-user"></i>
</div>
<span className="hidden sm:inline">{user.name}</span>
</button>
)}
</div>
</div>
</header>
);
}
+220
View File
@@ -0,0 +1,220 @@
import type { MenuCategory, Product, ShopInfo, SocialLinks, User } from "./types";
// ===== SHOP INFORMATION =====
export const SHOP_INFO: ShopInfo = {
name: "Coffee Shop",
tagline: "Hương vị đậm đà Khoảnh khắc thư giãn",
logo: "/imgs/logo.png",
address: "123 Đường Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
phone: "0901 234 567",
managerPhone: "0912 345 678",
email: "contact@coffeeshop.vn",
wifi: {
name: "CoffeeShop_Free",
password: "coffee2024",
},
openHours: "07:00 22:00 (Thứ 2 Chủ nhật)",
};
// ===== SOCIAL LINKS =====
export const SOCIAL_LINKS: SocialLinks = {
facebook: "https://facebook.com/coffeeshop",
tiktok: "https://tiktok.com/@coffeeshop",
website: "/",
};
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "Trà", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{ id: "giai-khat", name: "Giải Khát / Ăn Vặt", icon: "fa-solid fa-ice-cream" },
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
];
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: Product[] = [
{
id: 1,
name: "Cà Phê Đen",
category: "cafe",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description: "Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
available: true,
},
{
id: 2,
name: "Cà Phê Sữa",
category: "cafe",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description: "Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
available: true,
},
{
id: 3,
name: "Bạc Xỉu",
category: "cafe",
price: 32000,
image: "/imgs/products/placeholder.jpg",
description: "Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
available: true,
},
{
id: 4,
name: "Cà Phê Trứng",
category: "cafe",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description: "Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
available: true,
},
{
id: 5,
name: "Trà Đào Cam Sả",
category: "tra",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description: "Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
available: true,
},
{
id: 6,
name: "Trà Xanh Matcha",
category: "tra",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description: "Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
available: true,
},
{
id: 7,
name: "Trà Vải Hoa Nhài",
category: "tra",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description: "Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
available: true,
},
{
id: 8,
name: "Sữa Chua Trân Châu",
category: "sua-chua",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description: "Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
available: true,
},
{
id: 9,
name: "Sữa Chua Dâu",
category: "sua-chua",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description: "Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
available: true,
},
{
id: 10,
name: "Nước Ép Cam",
category: "nuoc-ep",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description: "Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
available: true,
},
{
id: 11,
name: "Nước Ép Dưa Hấu",
category: "nuoc-ep",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description: "Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
available: true,
},
{
id: 12,
name: "Latte Caramel",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description: "Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
available: true,
},
{
id: 13,
name: "Latte Vanilla",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description: "Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
available: true,
},
{
id: 14,
name: "Bánh Mì Nướng Bơ",
category: "giai-khat",
price: 20000,
image: "/imgs/products/placeholder.jpg",
description: "Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
available: true,
},
{
id: 15,
name: "Bánh Flan",
category: "giai-khat",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description: "Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
available: true,
},
{
id: 16,
name: "Trân Châu Đen",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description: "Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
available: true,
},
{
id: 17,
name: "Thạch Cà Phê",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
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) =====
export const MOCK_USERS: Record<string, User> = {
manager: {
id: 1,
name: "Nguyễn Văn An",
role: "manager",
avatar: null,
},
staff: {
id: 2,
name: "Trần Thị Bình",
role: "staff",
avatar: null,
},
};
+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);
}
+52
View File
@@ -0,0 +1,52 @@
// ===== USER TYPES =====
export type UserRole = "manager" | "staff" | "customer";
export interface User {
id: number;
name: string;
role: UserRole;
avatar: string | null;
}
// ===== MENU TYPES =====
export interface MenuCategory {
id: string;
name: string;
icon: string; // FontAwesome class e.g. "fa-solid fa-mug-hot"
}
// ===== PRODUCT TYPES =====
export interface Product {
id: number;
name: string;
category: string; // matches MenuCategory.id
price: number;
image: string;
description: string;
available?: boolean;
}
// ===== SHOP INFO TYPES =====
export interface WifiInfo {
name: string;
password: string;
}
export interface ShopInfo {
name: string;
tagline: string;
logo: string;
address: string;
phone: string;
managerPhone: string;
email: string;
wifi: WifiInfo;
openHours: string;
}
// ===== SOCIAL LINKS TYPES =====
export interface SocialLinks {
facebook: string;
tiktok: string;
website: 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;
+8041
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,7 +11,8 @@
"dependencies": {
"next": "16.1.7",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+3 -1
View File
@@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"forceConsistentCasingInFileNames": true,
"plugins": [
{
"name": "next"
@@ -28,7 +29,8 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"**/*.mts",
"types/**/*.d.ts"
],
"exclude": ["node_modules"]
}
+7
View File
@@ -0,0 +1,7 @@
/* Tell TypeScript that CSS files are valid side-effect imports.
Next.js handles the actual bundling; this declaration silences
the TS language-server warning for `import "./globals.css"`. */
declare module "*.css" {
const content: Record<string, string>;
export default content;
}