Merge pull request 'feat: login and register page' (#4) from dev-created-login-and-register-page into main
Reviewed-on: #4
@@ -1,26 +1,31 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/alpine
|
||||
{
|
||||
"name": "Alpine",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/base:alpine-3.22",
|
||||
"name": "Alpine",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/base:alpine-3.22",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
|
||||
"packages": "pnpm",
|
||||
"upgradePackages": true
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
|
||||
"packages": "pnpm",
|
||||
"upgradePackages": true
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
}
|
||||
}
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "uname -a",
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "uname -a",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Release package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev-*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.set_output.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Update dependencies
|
||||
run: pnpm update
|
||||
|
||||
- name: Format code
|
||||
run: pnpm format
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITEA_USER: ${{ github.actor }}
|
||||
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: pnpm release
|
||||
|
||||
- name: Set Version Output
|
||||
id: set_output
|
||||
run: |
|
||||
# Đọc version hiện tại trong package.json sau khi release
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit & Push Changes
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git config --global user.name "gitea-actions"
|
||||
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
||||
|
||||
git rm -r --cached .
|
||||
git add .
|
||||
git commit -m "chore: release [ci skip]"
|
||||
git push
|
||||
fi
|
||||
|
||||
build-and-push:
|
||||
needs: release
|
||||
# if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.demonkernel.io.vn
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Prepare Docker Metadata
|
||||
id: meta
|
||||
run: |
|
||||
# Lowercase Repository
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Lấy version từ job release và lowercase nó (cho các bản prerelease)
|
||||
RAW_VERSION="${{ needs.release.outputs.new_version }}"
|
||||
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
deploy-via-portainer:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Portainer Webhook
|
||||
run: |
|
||||
curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
|
||||
@@ -9,6 +9,7 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
/.pnpm-store
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -19,6 +20,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/release.zip
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"importOrder": ["^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"proseWrap": "always",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-groovy",
|
||||
"prettier-plugin-embed",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"importOrderParserPlugins": ["typescript", "decorators-legacy", "jsx"],
|
||||
"embeddedMarkdownComments": ["tw", "tx"],
|
||||
"printWidth": 80,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"tailwindAttributes": ["/.*(C|c)lassName/"]
|
||||
}
|
||||
@@ -1,36 +1,120 @@
|
||||
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
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
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.
|
||||
Mở trình duyệt tại http://localhost:3000
|
||||
|
||||
## Learn More
|
||||
### Build
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
```bash
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
- [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.
|
||||
### Lint
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Deploy on Vercel
|
||||
---
|
||||
|
||||
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ấu Trúc Thư Mục
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Công Nghệ Sử Dụng
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -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); }
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import CartFab from "@/components/CartFab";
|
||||
import Footer from "@/layouts/footer";
|
||||
import Header from "@/layouts/header";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +18,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 +33,32 @@ 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} flex min-h-screen flex-col antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Providers>
|
||||
{/* Sticky top header */}
|
||||
<Header />
|
||||
|
||||
{/* Page content (grows to fill remaining height) */}
|
||||
<div className="flex-1">{children}</div>
|
||||
|
||||
{/* Footer always at bottom */}
|
||||
<Footer />
|
||||
|
||||
{/* Global floating cart button */}
|
||||
<CartFab />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors = { username: "", password: "", general: "" };
|
||||
let isValid = true;
|
||||
|
||||
if (!username.trim()) {
|
||||
newErrors.username = "Vui lòng nhập tên đăng nhập";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
newErrors.password = "Vui lòng nhập mật khẩu";
|
||||
isValid = false;
|
||||
} else if (password.length < 4) {
|
||||
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
const success = login(username, password);
|
||||
|
||||
if (success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setErrors({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{/* Login Form Card */}
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative mb-4 h-20 w-20">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Đăng nhập vào hệ thống
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Tên đăng nhập
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-user absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
}}
|
||||
placeholder="admin / số điện thoại / tên nhân viên"
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.username ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mật khẩu
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-lock absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
placeholder="Nhập mật khẩu"
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 pr-11 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
aria-label={showPassword ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
|
||||
{/* Register Button */}
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Đăng ký tài khoản
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Demo Credentials Info */}
|
||||
<div className="bg-background mt-6 rounded-lg p-4">
|
||||
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
|
||||
Tài khoản demo:
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-(--color-text-muted)">
|
||||
<li>
|
||||
• Quản lý:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
admin / admin
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Nhân viên:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
Nguyễn Văn An / Nguyễn Văn An
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Khách hàng:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
0987654321 / user1
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,191 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import CartProduct from "@/components/CartProduct";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const { addToCart } = useCart();
|
||||
|
||||
/* 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="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
||||
{/* ── Sidebar ── */}
|
||||
<Navbar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={() => setIsSidebarOpen((prev) => !prev)}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
/>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
|
||||
{/* ── Section heading + search bar ── */}
|
||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
{/* Title + count */}
|
||||
<div className="shrink-0">
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
{activeCategoryLabel}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{filteredProducts.length} món
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm món..."
|
||||
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
||||
/>
|
||||
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 top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
||||
>
|
||||
<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="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
|
||||
<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 shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap 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} shrink-0 text-sm ${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={() => addToCart(product)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 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="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
||||
>
|
||||
Xóa tìm kiếm
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentPage() {
|
||||
const {
|
||||
items,
|
||||
totalPrice,
|
||||
increaseQty,
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
} = useCart();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
||||
<div className="flex flex-col gap-6 xl:flex-row">
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)">
|
||||
<div className="border-b border-(--color-border-light) px-4 py-3">
|
||||
<h1 className="text-foreground text-lg font-bold md:text-xl">
|
||||
Trang thanh toán
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-(--color-text-muted)">
|
||||
Chưa có sản phẩm nào trong giỏ hàng.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-190 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-(--color-border-light)/40 text-left">
|
||||
<th className="px-4 py-3 font-semibold">Tên sản phẩm</th>
|
||||
<th className="px-4 py-3 font-semibold">Giá tiền</th>
|
||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||
<th className="px-4 py-3 font-semibold">Số lượng</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">
|
||||
Xóa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-(--color-border-light)"
|
||||
>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{item.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(item.id)}
|
||||
className="h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Giảm số lượng ${item.name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(item.id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Nhập số lượng"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(item.id)}
|
||||
className="h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Tăng số lượng ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-red-500 px-3 py-2 text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
<i className="fa-solid fa-trash"></i>
|
||||
<span className="hidden lg:inline">
|
||||
Xóa sản phẩm
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="shrink-0 xl:w-85">
|
||||
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
||||
<span className="text-(--color-text-muted)">Tổng cộng</span>
|
||||
<span className="text-xl font-bold text-(--color-primary)">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-(--color-primary) px-3 py-2.5 text-white transition-colors hover:bg-(--color-primary-dark)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-money-bill-wave"></i>
|
||||
<span className="hidden lg:inline">Tiền mặt</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="text-foreground inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-(--color-border) px-3 py-2.5 transition-colors hover:bg-(--color-border-light)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-qrcode"></i>
|
||||
<span className="hidden lg:inline">QR Code</span>
|
||||
</button>
|
||||
|
||||
<Link href="/">
|
||||
<button
|
||||
className="text-foreground inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-(--color-border) px-3 py-2.5 transition-colors hover:bg-(--color-border-light)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-rotate-left"></i>
|
||||
<span className="hidden lg:inline">Quay về</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</MenuProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
// Static OTP for demo (in production, this would be sent via SMS)
|
||||
const DEMO_OTP = "123456";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { completeRegistration } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [errors, setErrors] = useState({ phone: "", otp: "" });
|
||||
|
||||
// Validate Vietnamese phone number
|
||||
const validatePhone = (phoneNumber: string): boolean => {
|
||||
// Vietnamese phone format: 10 digits starting with 0
|
||||
// Valid prefixes: 03, 05, 07, 08, 09
|
||||
const phoneRegex = /^(0[3|5|7|8|9])[0-9]{8}$/;
|
||||
return phoneRegex.test(phoneNumber);
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({
|
||||
...errors,
|
||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to OTP step
|
||||
setStep("otp");
|
||||
setErrors({ phone: "", otp: "" });
|
||||
};
|
||||
|
||||
const handleOtpSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (otp !== DEMO_OTP) {
|
||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete registration
|
||||
completeRegistration(phone);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleBackToPhone = () => {
|
||||
setStep("phone");
|
||||
setOtp("");
|
||||
setErrors({ phone: "", otp: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{/* Register Form Card */}
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative mb-4 h-20 w-20">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone"
|
||||
? "Đăng ký tài khoản khách hàng"
|
||||
: "Xác thực số điện thoại"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
step === "phone"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-(--color-accent-light) text-(--color-primary-dark)"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-(--color-border)"></div>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
step === "otp"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone Step */}
|
||||
{step === "phone" && (
|
||||
<form onSubmit={handlePhoneSubmit} className="space-y-5">
|
||||
{/* Phone Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Số điện thoại
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.phone}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Tiếp tục
|
||||
</button>
|
||||
|
||||
{/* Back to Login */}
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP Step */}
|
||||
{step === "otp" && (
|
||||
<form onSubmit={handleOtpSubmit} className="space-y-5">
|
||||
{/* Info Message */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="mb-2 text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Demo OTP:{" "}
|
||||
<code className="rounded bg-white px-2 py-1 font-mono">
|
||||
{DEMO_OTP}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mã OTP
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="otp"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ ...errors, otp: "" });
|
||||
}}
|
||||
placeholder="Nhập mã OTP"
|
||||
maxLength={6}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.otp}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Hoàn tất đăng ký
|
||||
</button>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToPhone}
|
||||
className="w-full rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Thay đổi số điện thoại
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP (disabled in demo) */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
||||
>
|
||||
Gửi lại mã OTP (60s)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CartFab() {
|
||||
const { totalItems } = useCart();
|
||||
|
||||
if (totalItems <= 0) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/payment"
|
||||
aria-label="Đi đến trang thanh toán"
|
||||
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-cart-shopping text-lg"></i>
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-6 min-w-6 items-center justify-center rounded-full border-2 border-white bg-(--color-accent) px-1.5 text-xs font-bold text-(--color-primary-dark)">
|
||||
{totalItems}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"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 w-full cursor-default flex-col overflow-hidden rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) shadow-[0_2px_8px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-0.5 hover:shadow-[0_6px_20px_var(--color-shadow-md)]">
|
||||
{/* ── Image area ── */}
|
||||
<div className="relative h-36 w-full shrink-0 overflow-hidden bg-(--color-border-light)">
|
||||
{/* Fallback icon (shown when image fails or is missing) */}
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center text-4xl text-(--color-border)">
|
||||
<i className="fa-solid fa-mug-hot"></i>
|
||||
</div>
|
||||
{/* Product image */}
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
className="z-1 object-cover"
|
||||
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 flex-1 flex-col gap-1 px-3 pt-2.5 pb-1.5">
|
||||
<h3 className="text-foreground line-clamp-1 text-sm leading-tight font-bold">
|
||||
{productName}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-(--color-text-muted)">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Price + Buy button ── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--color-border-light) px-3 py-2.5">
|
||||
<span className="text-sm font-bold text-(--color-primary)">
|
||||
{formattedPrice}
|
||||
</span>
|
||||
<button
|
||||
onClick={onBuy}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-(--color-primary) px-3 py-1.5 text-xs font-semibold whitespace-nowrap text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-cart-plus"></i>
|
||||
Mua
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"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 z-20 hidden shrink-0 flex-col overflow-x-hidden overflow-y-auto border-r border-(--color-border) bg-(--color-bg-sidebar) transition-all duration-250 ease-in-out 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 shrink-0 items-center border-b border-(--color-border) 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 tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${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="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) 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={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${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 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
|
||||
{/* Label — hidden when collapsed, always shown on xl+ */}
|
||||
<span
|
||||
className={`overflow-hidden text-ellipsis whitespace-nowrap ${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" : "flex justify-center px-0"} `}
|
||||
>
|
||||
{/* 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 shrink-0 text-(--color-accent)"></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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# --- Giai đoạn 1: Build ---
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
# Cài đặt pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy file định nghĩa package
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Cài đặt dependencies (sử dụng --frozen-lockfile để đảm bảo đúng phiên bản)
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy toàn bộ code
|
||||
COPY . .
|
||||
|
||||
# Build Next.js (Yêu cầu next.config.js có output: 'export')
|
||||
RUN pnpm run build
|
||||
|
||||
# --- Giai đoạn 2: Run (Sản phẩm cuối) ---
|
||||
FROM nginx:alpine
|
||||
|
||||
# Xóa file mặc định của nginx (tùy chọn nhưng nên làm)
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy folder out từ giai đoạn builder
|
||||
COPY --from=builder /app/out /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: drinkool_frontend
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx-container
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
limits:
|
||||
cpu: "50m"
|
||||
memory: "32Mi"
|
||||
requests:
|
||||
cpu: "10m"
|
||||
memory: "16Mi"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: my-web-service
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30080
|
||||
@@ -0,0 +1,181 @@
|
||||
import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* 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 overflow-x-hidden bg-(--color-bg-footer) text-(--color-text-on-dark)">
|
||||
{/* ── Main grid ── */}
|
||||
<div className="mx-auto max-w-screen-2xl px-4 py-10 md:px-6 lg:px-8">
|
||||
<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="mb-3 flex items-center gap-3">
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={`Logo ${SHOP_INFO.name}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-(--color-accent)">
|
||||
{SHOP_INFO.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="mb-4 text-sm leading-relaxed opacity-75">
|
||||
{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 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<span>Địa chỉ: {SHOP_INFO.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<a
|
||||
href={`tel:${SHOP_INFO.phone}`}
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
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 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<a
|
||||
href={`mailto:${SHOP_INFO.email}`}
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
Email: {SHOP_INFO.email}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-clock w-4 shrink-0 text-center text-(--color-accent)"></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 grid grid-cols-1 gap-6 md:col-span-6 lg:col-span-4 xl:col-span-6 xl:grid-cols-2">
|
||||
{/* ── 2. Social links ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
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 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#1877F2] text-base text-white">
|
||||
<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 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-black text-base text-white">
|
||||
<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 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-(--color-primary) text-base text-white">
|
||||
<i className="fa-solid fa-globe"></i>
|
||||
</span>
|
||||
Website
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* ── 3. WiFi card ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
WiFi Miễn Phí
|
||||
</h3>
|
||||
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
|
||||
<span className="text-sm font-semibold">
|
||||
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="text-xs tracking-wide uppercase opacity-60">
|
||||
Tên mạng
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Mật khẩu
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.password}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom bar ── */}
|
||||
<div className="border-opacity-10 border-t border-white">
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs opacity-50 sm:flex-row md:px-6 lg:px-8">
|
||||
<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 mx-1 text-(--color-accent)"></i>{" "}
|
||||
bằng Drinkool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Site Header — sticky top bar, always visible on all screen sizes.
|
||||
*
|
||||
* 2-column layout:
|
||||
* [LEFT: Brand (logo + name + tagline)] | [RIGHT: Auth button]
|
||||
*
|
||||
* Auth states:
|
||||
* - Guest: Shows "Đăng nhập" button → navigates to /login
|
||||
* - Manager: Shows "Quản lý" badge with logout on click
|
||||
* - Staff: Shows staff name with logout on click
|
||||
* - Customer: Shows "Khách hàng" with phone and logout on click
|
||||
*
|
||||
* 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 router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleAuthClick = () => {
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 h-(--spacing-header-height) w-full border-b border-(--color-border) bg-(--color-bg-header) shadow-[0_1px_8px_var(--color-shadow-sm)]">
|
||||
<div className="mx-auto flex h-full max-w-screen-2xl items-center justify-between gap-6 px-6 md:px-8 lg:px-12">
|
||||
{/* ── LEFT: Brand ── */}
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex shrink-0 items-center gap-4 no-underline"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
|
||||
<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="text-base font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-lg">
|
||||
{SHOP_INFO.name}
|
||||
</span>
|
||||
<span className="hidden text-xs text-(--color-text-muted) md:block">
|
||||
{SHOP_INFO.tagline}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* ── RIGHT: Auth ── */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!user ? (
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng nhập"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<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={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) transition-all duration-150 hover:bg-(--color-accent) hover:text-white active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-user-tie text-base"></i>
|
||||
<span className="hidden sm:inline">Quản lý</span>
|
||||
</button>
|
||||
) : user.role === "staff" ? (
|
||||
/* Staff: avatar + name */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-primary-light) text-xs text-white">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">{user.name}</span>
|
||||
</button>
|
||||
) : (
|
||||
/* Customer: phone icon + label */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
|
||||
>
|
||||
{/* Customer icon */}
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">Khách hàng</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => boolean;
|
||||
logout: () => void;
|
||||
registerPhone: string | null;
|
||||
setRegisterPhone: (phone: string | null) => void;
|
||||
completeRegistration: (phone: string) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// Mock user database
|
||||
const MOCK_AUTH_DB = {
|
||||
// Admin
|
||||
admin: {
|
||||
username: "admin",
|
||||
password: "admin",
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Quản lý",
|
||||
role: "manager" as const,
|
||||
avatar: null,
|
||||
phone: "0912345678",
|
||||
},
|
||||
},
|
||||
|
||||
// Staff (username and password are their names)
|
||||
"Nguyễn Văn An": {
|
||||
username: "Nguyễn Văn An",
|
||||
password: "Nguyễn Văn An",
|
||||
user: {
|
||||
id: 2,
|
||||
name: "Nguyễn Văn An",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0901234567",
|
||||
},
|
||||
},
|
||||
"Trần Thị Bình": {
|
||||
username: "Trần Thị Bình",
|
||||
password: "Trần Thị Bình",
|
||||
user: {
|
||||
id: 3,
|
||||
name: "Trần Thị Bình",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0902345678",
|
||||
},
|
||||
},
|
||||
"Lê Văn Cường": {
|
||||
username: "Lê Văn Cường",
|
||||
password: "Lê Văn Cường",
|
||||
user: {
|
||||
id: 4,
|
||||
name: "Lê Văn Cường",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0903456789",
|
||||
},
|
||||
},
|
||||
|
||||
// Customers (username is phone number, password is custom)
|
||||
"0987654321": {
|
||||
username: "0987654321",
|
||||
password: "user1",
|
||||
user: {
|
||||
id: 5,
|
||||
name: "Khách hàng",
|
||||
role: "customer" as const,
|
||||
avatar: null,
|
||||
phone: "0987654321",
|
||||
},
|
||||
},
|
||||
"0976543210": {
|
||||
username: "0976543210",
|
||||
password: "user1",
|
||||
user: {
|
||||
id: 6,
|
||||
name: "Khách hàng",
|
||||
role: "customer" as const,
|
||||
avatar: null,
|
||||
phone: "0976543210",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (username: string, password: string): boolean => {
|
||||
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
|
||||
|
||||
if (authEntry && authEntry.password === password) {
|
||||
setUser(authEntry.user);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("coffee-shop-user");
|
||||
};
|
||||
|
||||
const completeRegistration = (phone: string) => {
|
||||
// Create new customer account
|
||||
const newUser: User = {
|
||||
id: Date.now(),
|
||||
name: "Khách hàng",
|
||||
role: "customer",
|
||||
avatar: null,
|
||||
phone,
|
||||
};
|
||||
|
||||
// Add to mock database (in real app, this would be API call)
|
||||
(MOCK_AUTH_DB as any)[phone] = {
|
||||
username: phone,
|
||||
password: "user1", // Default password for new customers
|
||||
user: newUser,
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
|
||||
setRegisterPhone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartContextValue {
|
||||
items: CartItem[];
|
||||
totalItems: number;
|
||||
totalPrice: number;
|
||||
addToCart: (product: Product) => void;
|
||||
increaseQty: (id: number) => void;
|
||||
decreaseQty: (id: number) => void;
|
||||
removeFromCart: (id: number) => void;
|
||||
setQuantity: (id: number, quantity: number) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "coffee-shop-cart";
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as CartItem[];
|
||||
if (Array.isArray(parsed)) {
|
||||
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
const addToCart = (product: Product) => {
|
||||
setItems((prev) => {
|
||||
const index = prev.findIndex((i) => i.id === product.id);
|
||||
if (index === -1) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const increaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const decreaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const removeFromCart = (id: number) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
: 0;
|
||||
if (safeQty === 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const totalItems = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
items,
|
||||
totalItems,
|
||||
totalPrice,
|
||||
addToCart,
|
||||
increaseQty,
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
}),
|
||||
[items, totalItems, totalPrice],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) {
|
||||
throw new Error("useCart must be used within CartProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// ===== USER TYPES =====
|
||||
export type UserRole = "manager" | "staff" | "customer";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar: string | null;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
// ===== 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;
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
/* config options here */
|
||||
output: "export", // Bắt buộc để tạo ra thư mục /out
|
||||
images: {
|
||||
unoptimized: true, // Thường cần thiết cho static export
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0-created-login-and-register-page.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"format": "prettier --write \"./**/*.{js,jsx,ts,tsx,json,md}\"",
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/react": "^19.2.14",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||
"@semantic-release/exec": "^7.1.0",
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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 +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 |
|
After Width: | Height: | Size: 37 KiB |
@@ -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 +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 +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 |
@@ -0,0 +1,40 @@
|
||||
import type { GlobalConfig } from "semantic-release";
|
||||
|
||||
const config: GlobalConfig = {
|
||||
tagFormat: "v${version}",
|
||||
repositoryUrl: "https://git.demonkernel.io.vn/FoodSurf/frontend.git",
|
||||
branches: [
|
||||
"main",
|
||||
{
|
||||
name: "dev-*",
|
||||
prerelease: "${name.replace('dev-', '')}",
|
||||
channel: "${name.replace('dev-', '')}",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
["@semantic-release/npm", { npmPublish: false }],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
prepareCmd:
|
||||
"pnpm build && node scripts/release.ts ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@saithodev/semantic-release-gitea",
|
||||
{
|
||||
giteaUrl: "https://git.demonkernel.io.vn/",
|
||||
assets: [
|
||||
{
|
||||
path: "release.zip",
|
||||
name: "Drinkool v${nextRelease.version}.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const version = process.argv[2];
|
||||
if (!version) {
|
||||
console.error("❌ Version không được cung cấp!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const zipPath = "release.zip";
|
||||
|
||||
console.log(`📦 Đang nén file cho version: ${version}...`);
|
||||
|
||||
execSync(`zip -r ${zipPath} out`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
console.log(`✅ Đã tạo file zip: ${zipPath}`);
|
||||
|
||||
execSync("pnpm format");
|
||||
@@ -13,6 +13,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
@@ -28,7 +29,7 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
"types/**/*.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "script", "*.ts"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||