Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dae381d182 | |||
| 4892099eaa | |||
| 9772a8f639 | |||
| d88b067c5c | |||
| e85fa90879 | |||
| 2875d39f2c | |||
| c99323ff9e | |||
| cb639704dd | |||
| 6d7dc0b43f | |||
| 80ff0af3d8 | |||
| 93552cb9d1 | |||
| 460e8a4ac1 | |||
| e326e11bb4 | |||
| 7e4a21d412 | |||
| 127cb32b16 | |||
| 4ddce518ac | |||
| 95d9435694 | |||
| 9d9a219ac8 | |||
| 32d40b1dbe | |||
| ace60e424b | |||
| 4e643303c3 | |||
| 949badaeeb | |||
| 7547260e90 | |||
| c68ea1f26f | |||
| 240155d1f6 | |||
| ff133edfda | |||
| 50473d799d | |||
| 4f3d807c95 | |||
| eca619b79a | |||
| d3a6d1164c | |||
| e211ee5835 | |||
| 17d82f7239 | |||
| e0e64ded9a |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: agent-orchestrator
|
||||
description: >
|
||||
Breaks down large, multi-step software tasks into structured sub-agents on the
|
||||
Claude Code CLI using the Task tool. Use this skill whenever the user
|
||||
describes a complex project or feature that has multiple components - e.g.,
|
||||
"build a login API with JWT and unit tests", "set up a CI/CD pipeline with
|
||||
Docker and GitHub Actions", "create a full-stack todo app with auth, REST API,
|
||||
and frontend". Trigger this skill when the task clearly has more than one
|
||||
distinct sub-system, layer, or concern that could be worked on in parallel or
|
||||
in sequence. DO NOT trigger for simple, single-step tasks like "write a
|
||||
function to reverse a string" or "fix this bug".
|
||||
---
|
||||
|
||||
# Agent Orchestrator
|
||||
|
||||
A skill for decomposing large engineering tasks into parallel and sequential
|
||||
sub-agents on Claude Code CLI using the **Task tool**.
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger when the user's task has **multiple distinct components** - for example:
|
||||
|
||||
- "Build a REST API with authentication, rate limiting, and unit tests"
|
||||
- "Set up a monorepo with shared packages, CI pipeline, and deployment configs"
|
||||
- "Create a data pipeline: ingest transform store visualize"
|
||||
|
||||
Do **not** trigger for single-step tasks (e.g., "rename this variable", "add a
|
||||
README").
|
||||
|
||||
---
|
||||
|
||||
## Prompt Template
|
||||
|
||||
Copy the block below and paste it into your Agent Code CLI session. Replace the
|
||||
placeholder in the `TASK` variable with your actual task description.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
You are a senior engineering orchestrator operating inside the Agent Code CLI.
|
||||
|
||||
TASK: "Build a login API with JWT and unit tests"
|
||||
|
||||
---
|
||||
|
||||
Your job is to analyze this task, decompose it into well-scoped sub-tasks, identify dependencies between them, and execute them using the Task tool.
|
||||
|
||||
Follow these steps exactly:
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 - Guard: Is this task complex enough?
|
||||
|
||||
If the task can be completed in a single step by a single agent (e.g., "rename a variable", "write one function"), respond with:
|
||||
> "This task is simple enough to handle directly - no orchestration needed."
|
||||
Then complete it yourself. Stop here.
|
||||
|
||||
Otherwise, continue.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2 - Decompose the task into sub-tasks
|
||||
|
||||
Analyze the TASK string. Break it into a flat list of concrete, independently scoped sub-tasks. Each sub-task must:
|
||||
- Have a clear, specific goal
|
||||
- Be executable by a single focused agent
|
||||
- Produce a concrete output (file, module, config, test suite, etc.)
|
||||
|
||||
Output the list in this format:
|
||||
|
||||
Sub-tasks:
|
||||
[T1] <sub-task title> - <one-sentence description>
|
||||
[T2] <sub-task title> - <one-sentence description>
|
||||
[T3] ...
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 - Identify dependencies and execution mode
|
||||
|
||||
For each sub-task, decide whether it can run in parallel with others, or must run after a specific predecessor.
|
||||
|
||||
Label each sub-task as one of:
|
||||
[PARALLEL] - no dependencies, can start immediately
|
||||
[SEQUENTIAL - after Tx] - must run only after sub-task Tx completes
|
||||
|
||||
Rules:
|
||||
- If a sub-task depends on an artifact produced by another (a file, schema, interface, module), mark it SEQUENTIAL.
|
||||
- If two sub-tasks are fully independent (different files, no shared state), mark both PARALLEL.
|
||||
- Do not start any SEQUENTIAL sub-task until its prerequisite is confirmed complete.
|
||||
|
||||
Output the dependency plan in this format:
|
||||
|
||||
Dependency Plan:
|
||||
[T1] <title> [PARALLEL]
|
||||
[T2] <title> [PARALLEL]
|
||||
[T3] <title> [SEQUENTIAL - after T1]
|
||||
[T4] <title> [SEQUENTIAL - after T2, T3]
|
||||
|
||||
---
|
||||
|
||||
## STEP 4 - Confirm plan before execution
|
||||
|
||||
Print the full decomposition and dependency plan to the user.
|
||||
Then print:
|
||||
> "Starting execution. Spawning parallel sub-agents now..."
|
||||
|
||||
Do not skip this confirmation step.
|
||||
|
||||
---
|
||||
|
||||
## STEP 5 - Execute using the Task tool
|
||||
|
||||
Use the **Task tool** to spawn sub-agents. Follow this protocol:
|
||||
|
||||
### 5a. Spawn all [PARALLEL] sub-tasks simultaneously
|
||||
- Call the Task tool for each PARALLEL sub-task at the same time (do not wait for one to finish before spawning the next).
|
||||
- Each Task call must include a self-contained prompt describing exactly what to build, where to put files, and what the expected output is.
|
||||
|
||||
### 5b. Monitor and gate SEQUENTIAL sub-tasks
|
||||
- Wait for each prerequisite Task to complete before spawning its dependent.
|
||||
- Once a prerequisite completes, immediately spawn the next Task.
|
||||
|
||||
### 5c. Task prompt format
|
||||
Each task spawned via the Task tool must include:
|
||||
|
||||
- Role: "You are a focused sub-agent. Complete exactly this sub-task and nothing else."
|
||||
- Sub-task goal (from Step 2)
|
||||
- Output location (file path or module name)
|
||||
- Any relevant interfaces or contracts from predecessor tasks (if SEQUENTIAL)
|
||||
- Instruction to report back: "When done, summarize what you built and list output files."
|
||||
|
||||
---
|
||||
|
||||
## STEP 6 - Synthesize and report
|
||||
|
||||
After all Tasks complete:
|
||||
1. Collect each sub-agent's summary.
|
||||
2. Verify that all expected outputs exist.
|
||||
3. Report to the user:
|
||||
- What was built
|
||||
- File/module structure
|
||||
- Any issues or gaps found
|
||||
- Suggested next steps (e.g., "Run `npm test` to verify", "Review the generated OpenAPI spec")
|
||||
|
||||
---
|
||||
|
||||
Begin now with STEP 1.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips for Customizing the Template
|
||||
|
||||
- **Replace the TASK value** with your actual task string. Keep it on one line,
|
||||
in quotes.
|
||||
- **Add context if needed**: Append lines after `TASK:` like
|
||||
`STACK: "Node.js, PostgreSQL, Jest"` or
|
||||
`CONSTRAINTS: "Use ESM modules only"` - the orchestrator will incorporate
|
||||
them.
|
||||
- **For monorepos or specific file layouts**: Add a `STRUCTURE:` hint, e.g.:
|
||||
```
|
||||
STRUCTURE: "src/api/, src/auth/, src/tests/, docker-compose.yml"
|
||||
```
|
||||
- **To limit parallelism** (e.g., on resource-constrained machines): Add
|
||||
`MAX_PARALLEL_AGENTS: 2` after the TASK line.
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
**Input task:**
|
||||
|
||||
> "Build a login API with JWT authentication and unit tests"
|
||||
|
||||
**Expected orchestrator output (before execution):**
|
||||
|
||||
```
|
||||
Sub-tasks:
|
||||
[T1] Project scaffold - Initialize Node.js project, folder structure, and dependencies
|
||||
[T2] Database schema - Define User model and migration for PostgreSQL
|
||||
[T3] Auth service - Implement JWT sign/verify logic and password hashing
|
||||
[T4] Login route - Build POST /auth/login endpoint with validation
|
||||
[T5] Middleware - Create JWT auth middleware for protected routes
|
||||
[T6] Unit tests - Write Jest tests for auth service and login route
|
||||
|
||||
Dependency Plan:
|
||||
[T1] Project scaffold [PARALLEL]
|
||||
[T2] Database schema [PARALLEL]
|
||||
[T3] Auth service [SEQUENTIAL - after T1]
|
||||
[T4] Login route [SEQUENTIAL - after T3]
|
||||
[T5] Middleware [SEQUENTIAL - after T3]
|
||||
[T6] Unit tests [SEQUENTIAL - after T4, T5]
|
||||
|
||||
Starting execution. Spawning parallel sub-agents now...
|
||||
```
|
||||
|
||||
T1 and T2 spawn immediately. T3 spawns once T1 completes. T4 and T5 spawn once
|
||||
T3 completes. T6 spawns last.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill requires Agent Code CLI with the **Task tool** enabled.
|
||||
- Each spawned sub-agent has no memory of the others - pass all needed context
|
||||
explicitly in the Task prompt.
|
||||
- For very large tasks (10+ sub-tasks), consider breaking the orchestration into
|
||||
phases and applying this skill recursively per phase.
|
||||
@@ -15,10 +15,10 @@
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [3000]
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "uname -a",
|
||||
|
||||
@@ -22,16 +22,9 @@ jobs:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies with Proxy
|
||||
env:
|
||||
http_proxy: "http://host.docker.internal:3142"
|
||||
https_proxy: "http://host.docker.internal:3142"
|
||||
run: |
|
||||
# Kiểm tra proxy có hoạt động không
|
||||
echo "Acquire::http::Proxy \"$http_proxy\";" > /etc/apt/apt.conf.d/80proxy
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends --no-install-suggests \
|
||||
docker-cli zip docker-buildx
|
||||
- uses: gerlero/apt-install@v1
|
||||
with:
|
||||
packages: docker.io zip
|
||||
|
||||
- name: Setup SSH Key
|
||||
run: |
|
||||
@@ -50,19 +43,7 @@ jobs:
|
||||
with:
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
cache: true
|
||||
|
||||
- name: Update dependencies
|
||||
run: pnpm update
|
||||
|
||||
@@ -17,8 +17,10 @@ Chứa route/page/layout theo Next.js App Router.
|
||||
- **`(main)/`** - Main shopping interface (duyệt menu, đăng nhập, đăng ký,
|
||||
thanh toán)
|
||||
- **`(feed)/`** - Feed/discovery page (khám phá quán)
|
||||
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, đơn hàng) - **[ĐANG
|
||||
PHÁT TRIỂN]**
|
||||
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, phân tích tài
|
||||
chính) - **[ĐANG PHÁT TRIỂN ~70%]**
|
||||
- **`(staff)/`** - Staff shift schedule (lịch làm việc nhân viên) - **[HOÀN
|
||||
THÀNH]**
|
||||
- **Tài liệu:** `app/APP.md` - Chi tiết routes, pages, CSS tokens
|
||||
|
||||
### `components/` - Reusable UI Components (Atomic Design)
|
||||
@@ -80,7 +82,14 @@ Sections phức tạp kết hợp molecules + atoms, có logic, data filtering:
|
||||
- **`cart/`** - CartFab, CartSummary, CartList
|
||||
- **`product-grid/`** - ProductGrid, ProductFilters
|
||||
- **`forms/`** - LoginForm, RegisterForm, CheckoutForm, ReviewForm
|
||||
- **`modals/`** - ReviewModal, ConfirmModal
|
||||
- **`modals/`** - ReviewModal, ConfirmModal, CashPaymentModal, QRPaymentModal,
|
||||
PaymentSuccessModal
|
||||
- **`manager/`** - ProductModal (image upload), ComboModal, CategoryModal,
|
||||
DeleteConfirm, StatusBadge, ProductsTab, CombosTab, CategoriesTab
|
||||
- **`analytics/`** - BarChart, LineChart, PieChart (SVG), ProductTable,
|
||||
SummaryCard
|
||||
- **`shift-schedule/`** - WeeklySchedule, MonthlyCalendar, MobileShiftView,
|
||||
ShiftCreateModal, ShiftDetailModal
|
||||
- **`shop-grid/`** - ShopGrid, ShopFilters
|
||||
- **`hero-section/`** - HeroSection
|
||||
- **`featured-section/`** - FeaturedProducts, FeaturedShops
|
||||
@@ -103,6 +112,7 @@ Page layouts, không có data cụ thể, children composition:
|
||||
- **`main-layout/`** - MainLayout (header + sidebar + content + footer)
|
||||
- **`feed-layout/`** - FeedLayout
|
||||
- **`manager-layout/`** - ManagerLayout
|
||||
- **`staff-layout/`** - StaffLayout (navigation shell for staff pages)
|
||||
- **`checkout-layout/`** - CheckoutLayout
|
||||
- **`auth-layout/`** - AuthLayout
|
||||
|
||||
@@ -161,8 +171,12 @@ Chứa logic dùng chung, context, constants, types:
|
||||
- **`auth-context.tsx`** - AuthProvider + useAuth() hook (login, logout,
|
||||
register)
|
||||
- **`cart-context.tsx`** - CartProvider + useCart() hook (add, remove, quantity
|
||||
operations)
|
||||
operations, clearCart)
|
||||
- **`menu-context.tsx`** - MenuProvider + useMenu() hook (category state)
|
||||
- **`manager-context.tsx`** - ManagerProvider + useManager() hook (CRUD for
|
||||
products, combos, categories)
|
||||
- **`shift-context.tsx`** - ShiftProvider + useShift() hook (staff shift
|
||||
management: create, update, delete, date selection)
|
||||
- **Tài liệu:** `lib/LIB.md` - Chi tiết types, constants, contexts
|
||||
|
||||
### `public/` - Static Assets
|
||||
@@ -233,11 +247,41 @@ components/
|
||||
│ ├── product-grid/
|
||||
│ │ ├── ProductGrid.tsx
|
||||
│ │ └── ProductGrid.types.ts
|
||||
│ ├── modals/
|
||||
│ │ ├── ReviewModal.tsx
|
||||
│ │ ├── CashPaymentModal.tsx
|
||||
│ │ ├── QRPaymentModal.tsx
|
||||
│ │ └── PaymentSuccessModal.tsx
|
||||
│ ├── manager/
|
||||
│ │ ├── ProductModal.tsx
|
||||
│ │ ├── ComboModal.tsx
|
||||
│ │ ├── CategoryModal.tsx
|
||||
│ │ ├── DeleteConfirm.tsx
|
||||
│ │ ├── StatusBadge.tsx
|
||||
│ │ ├── ProductsTab.tsx
|
||||
│ │ ├── CombosTab.tsx
|
||||
│ │ └── CategoriesTab.tsx
|
||||
│ ├── analytics/
|
||||
│ │ ├── BarChart.tsx
|
||||
│ │ ├── LineChart.tsx
|
||||
│ │ ├── PieChart.tsx
|
||||
│ │ ├── ProductTable.tsx
|
||||
│ │ └── SummaryCard.tsx
|
||||
│ ├── shift-schedule/
|
||||
│ │ ├── WeeklySchedule.tsx
|
||||
│ │ ├── MonthlyCalendar.tsx
|
||||
│ │ ├── MobileShiftView.tsx
|
||||
│ │ ├── ShiftCreateModal.tsx
|
||||
│ │ └── ShiftDetailModal.tsx
|
||||
│ └── index.ts
|
||||
├── templates/
|
||||
│ ├── main-layout/
|
||||
│ │ ├── MainLayout.tsx
|
||||
│ │ └── MainLayout.types.ts
|
||||
│ ├── manager-layout/
|
||||
│ │ └── ManagerLayout.tsx
|
||||
│ ├── staff-layout/
|
||||
│ │ └── StaffLayout.tsx
|
||||
│ └── index.ts
|
||||
└── ATOMIC_DESIGN.md
|
||||
```
|
||||
@@ -314,9 +358,15 @@ components/
|
||||
- **Mock data first** - Use `lib/constants.ts` for development, replace with API
|
||||
later
|
||||
- **Contexts for state sharing:**
|
||||
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations
|
||||
- `CartContext` (lib/cart-context.tsx) - Shopping cart + operations
|
||||
- `MenuContext` (lib/menu-context.tsx) - Active category state
|
||||
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations →
|
||||
`useAuth()`
|
||||
- `CartContext` (lib/cart-context.tsx) - Shopping cart + operations →
|
||||
`useCart()` (includes `clearCart`)
|
||||
- `MenuContext` (lib/menu-context.tsx) - Active category state → `useMenu()`
|
||||
- `ManagerContext` (lib/manager-context.tsx) - Manager dashboard state (CRUD
|
||||
for products/combos/categories) → `useManager()`
|
||||
- `ShiftContext` (lib/shift-context.tsx) - Staff shift management (create,
|
||||
update, delete shifts, date selection) → `useShift()`
|
||||
- **localStorage keys:** `coffee-shop-user`, `coffee-shop-cart` (defined in
|
||||
contexts)
|
||||
|
||||
@@ -625,21 +675,33 @@ className = "w-full sm:w-1/2 md:w-1/3 p-4 md:p-6 hidden md:block";
|
||||
|
||||
## 5) Project Status
|
||||
|
||||
> Last Updated: 2026-04-18
|
||||
|
||||
### ✅ Completed Features
|
||||
|
||||
- User authentication (login, register, logout)
|
||||
- Product grid display with category filtering
|
||||
- Shopping cart with add/remove/update operations
|
||||
- Payment page with review modal
|
||||
- Payment page with PaymentSummaryCard + review modal
|
||||
- Cash payment modal (CashPaymentModal) with change calculation
|
||||
- QR payment modal (QRPaymentModal) with QR code generation (`qrcode.react`) and
|
||||
5-second auto-confirm countdown
|
||||
- Payment success modal (PaymentSuccessModal) with cart clear
|
||||
- Shop discovery (feed page)
|
||||
- Responsive design (mobile, tablet, desktop)
|
||||
- Header + Footer with navigation
|
||||
- Sidebar category filter
|
||||
- Manager dashboard — Products, Combos, Categories tabs with full CRUD
|
||||
- Product image upload in ProductModal
|
||||
- Financial Analytics dashboard (LineChart, BarChart, PieChart, ProductTable)
|
||||
- Staff shift schedule (`/staff/schedule`) — weekly/monthly views, mobile view,
|
||||
CRUD shifts, department filter
|
||||
|
||||
### 🚀 In Progress
|
||||
|
||||
- Manager dashboard (~70% complete) - `app/(manager)/` route group
|
||||
- Remaining: order management, inventory tracking, real API integration
|
||||
- Atomic Design refactoring (components reorganization)
|
||||
- Manager dashboard (quản lý sản phẩm) - `app/(manager)/` route group
|
||||
|
||||
### 📋 Planned Features
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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.
|
||||
|
||||
_Last Updated: 2026-04-18_
|
||||
|
||||
---
|
||||
|
||||
## Mô Tả Dự Án
|
||||
@@ -11,7 +13,9 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
||||
|
||||
### Tính Năng Hiện Tại
|
||||
|
||||
#### 1. **Trang Đăng Nhập & Đăng Ký** (`app/(main)/login`, `app/(main)/register`)
|
||||
#### Tính Năng Khách Hàng (Customer Features)
|
||||
|
||||
##### 1. **Trang Đăng Nhập & Đăng Ký** (`app/(main)/login`, `app/(main)/register`)
|
||||
|
||||
- Form đăng nhập với validation (username, password)
|
||||
- Form đăng ký với xác thực OTP
|
||||
@@ -19,7 +23,7 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
||||
- Hỗ trợ 3 loại tài khoản: Manager, Staff, Customer
|
||||
- Hiển thị thông tin shop trong form
|
||||
|
||||
#### 2. **Trang Khám Phá Quán Nước** (`app/(feed)/feed`)
|
||||
##### 2. **Trang Khám Phá Quán Nước** (`app/(feed)/feed`)
|
||||
|
||||
- Danh sách các quán cà phê
|
||||
- Tìm kiếm theo tên quán và địa chỉ
|
||||
@@ -27,9 +31,7 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
||||
- Responsive layout (grid 1/2/3 cột)
|
||||
- Empty state khi không tìm thấy quán
|
||||
|
||||
#### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
|
||||
|
||||
Dành cho khách hàng:
|
||||
##### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
|
||||
|
||||
- Sidebar collapsible (64px/240px) - danh mục sản phẩm
|
||||
- Grid sản phẩm responsive (1-5 cột tuỳ thiết bị)
|
||||
@@ -38,41 +40,52 @@ Dành cho khách hàng:
|
||||
- Lọc tự động theo trạng thái available
|
||||
- Mobile menu: scrollable category tabs (< md)
|
||||
|
||||
#### 4. **Trang Thanh Toán** (`app/(main)/payment`)
|
||||
##### 4. **Trang Thanh Toán** (`app/(main)/payment`)
|
||||
|
||||
- Bảng danh sách sản phẩm trong giỏ hàng với điều chỉnh số lượng
|
||||
- Invoice aside sticky với tổng giá và nút thanh toán (Tiền mặt, QR)
|
||||
- Modal đánh giá 5 sao dành riêng cho khách hàng (ReviewModal)
|
||||
- **CashPaymentModal:** nhập tiền mặt, tính tiền thối, báo lỗi nếu tiền không đủ
|
||||
- **QRPaymentModal:** tạo mã QR thanh toán (qrcode.react), tự xác nhận sau 5
|
||||
giây đếm ngược
|
||||
- **PaymentSuccessModal:** xác nhận thanh toán thành công, tự động xoá giỏ hàng
|
||||
|
||||
#### 5. **Hệ Thống Giỏ Hàng** (`lib/cart-context.tsx`)
|
||||
##### 5. **Hệ Thống Giỏ Hàng** (`lib/cart-context.tsx`)
|
||||
|
||||
- Lưu trữ trạng thái giỏ trong localStorage
|
||||
- Thêm/xóa/tăng/giảm số lượng sản phẩm
|
||||
- Tính tổng giá và số mặt hàng
|
||||
- Persist dữ liệu giữa các session
|
||||
- `clearCart()` để xoá toàn bộ giỏ sau thanh toán
|
||||
|
||||
#### 6. **Hệ Thống Xác Thực** (`lib/auth-context.tsx`)
|
||||
##### 6. **Hệ Thống Xác Thực** (`lib/auth-context.tsx`)
|
||||
|
||||
- Quản lý trạng thái người dùng (login/logout/register)
|
||||
- Lưu thông tin user trong localStorage
|
||||
- Mock auth database với 3 loại tài khoản
|
||||
- Hỗ trợ hoàn tất đăng ký qua OTP
|
||||
|
||||
#### 7. **Hệ Thống Danh Mục** (`lib/menu-context.tsx`)
|
||||
##### 7. **Hệ Thống Danh Mục** (`lib/menu-context.tsx`)
|
||||
|
||||
- Chia sẻ trạng thái category giữa Header mobile và Sidebar
|
||||
- Tự động clear search khi thay đổi category
|
||||
|
||||
#### 8. **Manager Dashboard** (`app/(manager)/manager`)
|
||||
#### Tính Năng Quản Lý (Manager Features)
|
||||
|
||||
Dành cho quản lý:
|
||||
##### 8. **Manager Dashboard** (`app/(manager)/manager`) — ~70% hoàn thành
|
||||
|
||||
- Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới
|
||||
Analytics
|
||||
- CRUD sản phẩm, combo, danh mục qua modals
|
||||
- Auth guard: tự động redirect non-manager về `/`
|
||||
- **Products tab:** CRUD sản phẩm qua ProductModal, bao gồm:
|
||||
- Upload ảnh sản phẩm với xem trước, nút "Đổi ảnh" và xoá ảnh
|
||||
- Giao diện vùng kéo thả với dashed border
|
||||
- Toggle trạng thái available
|
||||
- Dialog xác nhận xoá
|
||||
- **Combos tab:** CRUD combo qua ComboModal, toggle trạng thái, xác nhận xoá
|
||||
- **Categories tab:** CRUD danh mục qua CategoryModal, xác nhận xoá
|
||||
|
||||
#### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
|
||||
##### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
|
||||
|
||||
- Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh
|
||||
kỳ trước)
|
||||
@@ -81,6 +94,24 @@ Dành cho quản lý:
|
||||
- Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp
|
||||
- Lọc theo danh mục
|
||||
|
||||
#### Tính Năng Nhân Viên (Staff Features)
|
||||
|
||||
##### 10. **Lịch Làm Việc Nhân Viên** (`app/(staff)/staff/schedule`)
|
||||
|
||||
- Xem lịch theo tuần và theo tháng
|
||||
- Mobile shift view (danh sách ca theo ngày)
|
||||
- Lọc theo phòng ban / bộ phận
|
||||
- Modal tạo ca làm việc mới (ShiftCreateModal)
|
||||
- Modal xem chi tiết ca (ShiftDetailModal)
|
||||
- Auth guard: chỉ staff/manager mới truy cập được
|
||||
|
||||
#### Cải Thiện Tiếp Cận (Accessibility)
|
||||
|
||||
- Thuộc tính `title` trên các nút icon (tooltip cho screen reader)
|
||||
- ARIA roles trên modals: `role="dialog"`, `aria-modal`, `aria-labelledby`
|
||||
- Semantic HTML: nhãn form với `htmlFor` đúng chuẩn
|
||||
- `alt` text đầy đủ cho ảnh sản phẩm
|
||||
|
||||
---
|
||||
|
||||
## Cách Chạy Dự Án
|
||||
@@ -144,9 +175,12 @@ frondend/
|
||||
| | +-- layout.tsx # Feed layout
|
||||
| | +-- feed/page.tsx # Trang khám phá quán nước (/feed)
|
||||
| +-- (manager)/ # Manager route group
|
||||
| +-- layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||
| +-- manager/page.tsx # Manager Dashboard (/manager)
|
||||
| +-- manager/analytics/page.tsx # Financial Analytics (/manager/analytics)
|
||||
| | +-- layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||
| | +-- manager/page.tsx # Manager Dashboard (/manager)
|
||||
| | +-- manager/analytics/page.tsx # Financial Analytics (/manager/analytics)
|
||||
| +-- (staff)/ # Staff route group
|
||||
| +-- layout.tsx # Staff layout - auth guard
|
||||
| +-- staff/schedule/page.tsx # Lịch làm việc nhân viên (/staff/schedule)
|
||||
+-- components/ # Atomic Design UI components
|
||||
| +-- atoms/ # Nguyên tử: Button, Input, Badge, Text...
|
||||
| +-- molecules/ # Phân tử: ProductCard, ShopCard, SearchBar...
|
||||
@@ -202,8 +236,9 @@ frondend/
|
||||
| ---------------- | --------- | ------------------------------------- |
|
||||
| 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 |
|
||||
| TypeScript | ^5.9.3 | Kiểu dữ liệu tĩnh |
|
||||
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
|
||||
| qrcode.react | ^4.2.0 | Tạo mã QR thanh toán |
|
||||
| Geist Font | - | Font chữ (Google Fonts via next/font) |
|
||||
| FontAwesome | 6.7.2 | Icon library (CDN) |
|
||||
| pnpm | - | Package manager |
|
||||
@@ -224,14 +259,15 @@ frondend/
|
||||
- **Thanh Toán:** `app/(main)/payment/page.tsx`
|
||||
- **Manager Dashboard:** `app/(manager)/manager/page.tsx`
|
||||
- **Phân Tích Tài Chính:** `app/(manager)/manager/analytics/page.tsx`
|
||||
- **Lịch Làm Việc Nhân Viên:** `app/(staff)/staff/schedule/page.tsx`
|
||||
|
||||
### Tài Khoản Demo
|
||||
|
||||
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
|
||||
| -------------- | ------------- | ------------- |
|
||||
| Manager | admin | admin |
|
||||
| Staff | Nguyễn Văn An | Nguyễn Văn An |
|
||||
| Customer | 0987654321 | user1 |
|
||||
| Loại tài khoản | Tên đăng nhập | Mật khẩu / OTP |
|
||||
| -------------- | ------------- | -------------------------- |
|
||||
| Manager | admin | admin |
|
||||
| Staff | Nguyễn Văn An | Nguyễn Văn An |
|
||||
| Customer | 0987654321 | bất kỳ 6 chữ số (mock OTP) |
|
||||
|
||||
### Design & Styling
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Coffee Shop Frontend - TODO
|
||||
|
||||
_Last Updated: 2026-04-18_
|
||||
|
||||
---
|
||||
|
||||
## Completed Features & Implementations
|
||||
|
||||
### A. Dead Code Removed
|
||||
@@ -37,17 +41,66 @@
|
||||
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
|
||||
pages generated)
|
||||
|
||||
### F. Cart & Payment (Completed)
|
||||
|
||||
- [x] Cart checkout flow — cart context with addToCart, removeFromCart,
|
||||
setQuantity, clearCart
|
||||
- [x] Payment page implementation (`app/(main)/payment`)
|
||||
- [x] CashPaymentModal: cash input, change calculation, insufficient cash error
|
||||
- [x] QRPaymentModal: QR code generation via qrcode.react, 5-second countdown
|
||||
auto-confirm
|
||||
- [x] PaymentSuccessModal: success confirmation, auto-clears cart
|
||||
|
||||
### G. Manager Dashboard — ~70% Complete (In Progress)
|
||||
|
||||
- [x] Manager Dashboard page (`app/(manager)/manager/page.tsx`) with auth guard
|
||||
- [x] Products tab with CRUD (ProductModal)
|
||||
- [x] Image upload in ProductModal: file input, preview, "Đổi ảnh" button,
|
||||
remove button, dashed border drop zone
|
||||
- [x] Combos tab with CRUD (ComboModal)
|
||||
- [x] Categories tab with CRUD (CategoryModal)
|
||||
- [x] Delete confirmation dialogs for all entities
|
||||
- [x] Status toggle buttons (available/unavailable)
|
||||
- [x] Sidebar navigation with link to Analytics
|
||||
|
||||
### H. Financial Analytics Dashboard (Completed)
|
||||
|
||||
- [x] Analytics page (`app/(manager)/manager/analytics`)
|
||||
- [x] Summary cards: Revenue, Orders, Profit, Average Order Value (with period
|
||||
comparison)
|
||||
- [x] Period selector: Day / Week / Month / Year
|
||||
- [x] SVG charts: LineChart, BarChart, PieChart with hover tooltips
|
||||
- [x] Top 5 products table and sortable detail table
|
||||
- [x] Category filter
|
||||
|
||||
### I. Staff Shift Schedule (Completed)
|
||||
|
||||
- [x] Staff schedule module (`app/(staff)/staff/schedule`)
|
||||
- [x] Weekly calendar view
|
||||
- [x] Monthly calendar view
|
||||
- [x] Mobile shift view (list per day)
|
||||
- [x] ShiftCreateModal: create new shifts
|
||||
- [x] ShiftDetailModal: view shift details
|
||||
- [x] Department/role filtering
|
||||
- [x] Auth guard for staff/manager only
|
||||
|
||||
### J. Accessibility Improvements (Completed)
|
||||
|
||||
- [x] title attributes on icon buttons (tooltips for screen readers)
|
||||
- [x] ARIA roles on modals: role="dialog", aria-modal, aria-labelledby
|
||||
- [x] Semantic HTML: form labels with correct htmlFor
|
||||
- [x] Image alt text for product images
|
||||
|
||||
---
|
||||
|
||||
## Pending Features (Future Work)
|
||||
|
||||
### Cart & Ordering
|
||||
|
||||
- [ ] Implement cart checkout flow (app/(main)/cart or modal)
|
||||
- [ ] Cart sidebar/modal with item list and total
|
||||
- [ ] Cart sidebar/modal with item list and total (quick-access without going to
|
||||
payment page)
|
||||
- [ ] Order submission API integration
|
||||
- [ ] Payment page implementation (app/(main)/payment)
|
||||
- [ ] Order history/tracking page
|
||||
- [ ] Order history/tracking page for customers
|
||||
- [ ] Toast notifications for cart actions
|
||||
|
||||
### Authentication & User Management
|
||||
@@ -58,22 +111,22 @@
|
||||
- [ ] Password reset/recovery flow
|
||||
- [ ] Session management and token refresh
|
||||
|
||||
### Manager Features
|
||||
### Manager Features (Remaining ~30%)
|
||||
|
||||
- [ ] Manager dashboard page (app/(manager)/page.tsx)
|
||||
- [ ] Product management (add/edit/delete)
|
||||
- [ ] Category management
|
||||
- [ ] Order management & tracking
|
||||
- [ ] Sales analytics/dashboard
|
||||
- [ ] Inventory management
|
||||
- [ ] Order management & tracking for manager
|
||||
- [ ] Inventory management beyond available toggle (stock counts, low-stock
|
||||
alerts)
|
||||
- [ ] Staff management tab in Manager Dashboard
|
||||
- [ ] Shift approval workflow (manager approves staff shift requests)
|
||||
|
||||
### Backend Integration
|
||||
|
||||
- [ ] Replace MOCK_PRODUCTS with API calls (GET /api/products)
|
||||
- [ ] Replace MOCK_SHOPS with API calls (GET /api/shops)
|
||||
- [ ] Replace MOCK_USERS with real authentication (POST /api/auth/login)
|
||||
- [ ] Real product images (replace placeholder.jpg)
|
||||
- [ ] Image upload for products
|
||||
- [ ] Real product images hosted on server/CDN (replace public/imgs/products/)
|
||||
- [ ] Real analytics data from backend (GET /api/analytics)
|
||||
- [ ] Real shift/schedule data from backend
|
||||
|
||||
### UX Improvements
|
||||
|
||||
@@ -85,11 +138,12 @@
|
||||
- [ ] Filter by price range
|
||||
- [ ] Quantity selector in product card
|
||||
- [ ] Related products suggestions
|
||||
- [ ] Push notifications for order status updates
|
||||
|
||||
### Performance & SEO
|
||||
|
||||
- [ ] Dynamic route generation for products (app/(main)/product/[id]/page.tsx)
|
||||
- [ ] Dynamic route generation for shops (app/(feed)/shop/[id]/page.tsx)
|
||||
- [ ] Dynamic route generation for products (`app/(main)/product/[id]/page.tsx`)
|
||||
- [ ] Dynamic route generation for shops (`app/(feed)/shop/[id]/page.tsx`)
|
||||
- [ ] Meta tags and Open Graph for SEO
|
||||
- [ ] Image optimization and lazy loading
|
||||
- [ ] Code splitting and dynamic imports
|
||||
@@ -97,7 +151,7 @@
|
||||
### Accessibility & Testing
|
||||
|
||||
- [ ] Keyboard navigation testing
|
||||
- [ ] ARIA labels audit
|
||||
- [ ] Unit tests for contexts
|
||||
- [ ] E2E tests for user flows
|
||||
- [ ] Accessibility audit (WCAG 2.1 AA)
|
||||
- [ ] Full ARIA labels audit (WCAG 2.1 AA)
|
||||
- [ ] Unit tests for contexts (auth, cart, menu, manager)
|
||||
- [ ] E2E tests for user flows (checkout, login, manager CRUD)
|
||||
- [ ] Accessibility audit tool (axe, Lighthouse)
|
||||
|
||||
@@ -16,10 +16,10 @@ export default function FeedPage() {
|
||||
{/* Page title */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
|
||||
Explore Shops
|
||||
Khám phá quán nước
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||
Find and choose your favorite shop
|
||||
Tìm và chọn quán yêu thích của bạn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function FeedPage() {
|
||||
}}
|
||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
Xóa bộ lọc
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -46,7 +46,7 @@ export default function FeedPage() {
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
|
||||
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
||||
<span>Filter shops</span>
|
||||
<span>Lọc quán</span>
|
||||
</div>
|
||||
|
||||
{/* Name search */}
|
||||
@@ -54,7 +54,7 @@ export default function FeedPage() {
|
||||
value={searchName}
|
||||
onChange={setSearchName}
|
||||
onClear={() => setSearchName("")}
|
||||
placeholder="Search by shop name..."
|
||||
placeholder="Tìm theo tên quán..."
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
|
||||
@@ -65,13 +65,13 @@ export default function FeedPage() {
|
||||
type="text"
|
||||
value={searchAddress}
|
||||
onChange={(e) => setSearchAddress(e.target.value)}
|
||||
placeholder="Search by address..."
|
||||
placeholder="Tìm theo địa chỉ..."
|
||||
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
|
||||
/>
|
||||
{searchAddress && (
|
||||
<button
|
||||
onClick={() => setSearchAddress("")}
|
||||
aria-label="Clear address search"
|
||||
aria-label="Xóa tìm kiếm địa chỉ"
|
||||
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>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginOtpPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ otp: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || !storedRole) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
setRole(storedRole);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ otp: "Please enter your OTP code", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ otp: "", general: "" });
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
role === "manager"
|
||||
? "/api/manager/quick_login"
|
||||
: "/api/customer/quick_login";
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push(role === "manager" ? "/manager" : "/");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const msg =
|
||||
errorCode === "InvalidOTP"
|
||||
? "Incorrect or expired OTP code"
|
||||
: "An error occurred, please try again";
|
||||
setErrors({ otp: msg, general: "" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ otp: "", general: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<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)">
|
||||
Verify your phone number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Info */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
OTP code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
OTP code
|
||||
</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({ otp: "", general: "" });
|
||||
}}
|
||||
placeholder="Enter OTP code"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
<ErrorMessageLogin message={errors.otp} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/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) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Change phone number
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,39 @@ export default function LoginPage() {
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Enter your phone number to sign in
|
||||
Đăng nhập vào hệ thống
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<LoginForm />
|
||||
|
||||
{/* 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,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ password: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || storedRole !== "manager") {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password.trim()) {
|
||||
setErrors({ password: "Please enter your password", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ password: "", general: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, password, role: "manager" }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push("/manager");
|
||||
} else {
|
||||
const STATUS_ERROR_MAP: Record<number, string> = {
|
||||
400: "Incorrect login details, please try again",
|
||||
401: "Incorrect password",
|
||||
403: "Account is locked",
|
||||
404: "Account does not exist",
|
||||
};
|
||||
const msg =
|
||||
STATUS_ERROR_MAP[res.status] ??
|
||||
(res.status >= 500
|
||||
? "Server error, please try again later"
|
||||
: "An error occurred, please try again");
|
||||
setErrors({ password: "", general: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ password: "", general: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<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)">Manager sign in</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Phone display */}
|
||||
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
|
||||
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
|
||||
{phone}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Password
|
||||
</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({ password: "", general: "" });
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 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 ? "Hide password" : "Show password"}
|
||||
>
|
||||
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/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) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Change phone number
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
type PageState = "checking" | "available" | "closed" | "error";
|
||||
|
||||
export default function ManagerSignupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>("checking");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
|
||||
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/manager/signup")
|
||||
.then((res) => {
|
||||
setPageState("available")
|
||||
// if (res.ok) ;
|
||||
// else if (res.status === 403) setPageState("closed");
|
||||
// else setPageState("error");
|
||||
})
|
||||
.catch(() => setPageState("error"));
|
||||
}, []);
|
||||
|
||||
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
|
||||
|
||||
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [field]: e.target.value });
|
||||
setErrors({ ...errors, [field]: "", submit: "" });
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
|
||||
if (!form.name.trim()) next.name = "Please enter your full name";
|
||||
if (!form.phone.trim()) next.phone = "Please enter your phone number";
|
||||
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
|
||||
if (!form.password.trim()) next.password = "Please enter your password";
|
||||
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
|
||||
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
|
||||
setErrors(next);
|
||||
return !next.name && !next.phone && !next.password && !next.eateryName;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/manager/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok || res.status === 201) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const errorMap: Record<string, string> = {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ ...errors, submit: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<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)">Create a manager account</p>
|
||||
</div>
|
||||
|
||||
{/* Checking */}
|
||||
{pageState === "checking" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
|
||||
<p className="text-sm">Checking...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed */}
|
||||
{pageState === "closed" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Registration closed</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{pageState === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100">
|
||||
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Không thể kết nối</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng ký. Vui lòng thử lại.</p>
|
||||
</div>
|
||||
<Button variant="primaryNoBorder" style="login" size="lg" onClick={() => { setPageState("checking"); fetch("/api/manager/signup").then((r) => { if (r.ok) setPageState("available"); else if (r.status === 403) setPageState("closed"); else setPageState("error"); }).catch(() => setPageState("error")); }}>
|
||||
Thử lại
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-(--color-primary) underline">Quay lại đăng nhập</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signup Form */}
|
||||
{pageState === "available" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
|
||||
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
|
||||
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
|
||||
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
|
||||
].map(({ id, label, icon, placeholder, field, type }) => (
|
||||
<div key={id}>
|
||||
<label htmlFor={id} className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}></i>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={form[field]}
|
||||
onChange={handleChange(field)}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[field] && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors[field]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.submit && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<i className="fa-solid fa-circle-exclamation mr-2"></i>
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử lý...</>
|
||||
) : (
|
||||
"Đăng ký"
|
||||
)}
|
||||
</Button>
|
||||
<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"
|
||||
>
|
||||
Đã có tài khoản? Đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -38,7 +38,7 @@ export default function Home() {
|
||||
}, [activeCategory]);
|
||||
|
||||
const activeCategoryLabel =
|
||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
|
||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
||||
@@ -69,7 +69,7 @@ export default function Home() {
|
||||
setSearchQuery(q);
|
||||
}}
|
||||
onClear={() => setSearchQuery("")}
|
||||
placeholder="Search items..."
|
||||
placeholder="Tìm kiếm món..."
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
|
||||
import ReviewModal from "@/components/organisms/modals/ReviewModal";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { useState } from "react";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
@@ -21,9 +19,6 @@ export default function PaymentPage() {
|
||||
} = useCart();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const isCustomer = user?.role === "customer";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
||||
@@ -46,22 +41,22 @@ export default function PaymentPage() {
|
||||
<thead>
|
||||
<tr className="bg-(--color-border-light)/40 text-left">
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Product name
|
||||
Tên sản phẩm
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Price
|
||||
Giá tiền
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Description
|
||||
Mô tả
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Quantity
|
||||
Số lượng
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right font-semibold"
|
||||
>
|
||||
Delete
|
||||
Xóa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -116,7 +111,7 @@ export default function PaymentPage() {
|
||||
style="payment"
|
||||
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
|
||||
>
|
||||
Delete product
|
||||
Xóa sản phẩm
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -129,17 +124,13 @@ export default function PaymentPage() {
|
||||
</section>
|
||||
|
||||
<PaymentSummaryCard
|
||||
cartItems={items}
|
||||
totalPrice={totalPrice}
|
||||
isCustomer={isCustomer}
|
||||
role={user?.role ?? "customer"}
|
||||
backHref="/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-147
@@ -6,48 +6,19 @@ import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
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 { setUser } = useAuth();
|
||||
const { completeRegistration } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [errors, setErrors] = useState({ phone: "", otp: "" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [otpSending, setOtpSending] = useState(false);
|
||||
const [otpSendError, setOtpSendError] = useState("");
|
||||
|
||||
const sendOtp = async () => {
|
||||
setOtpSending(true);
|
||||
setOtpSendError("");
|
||||
try {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setOtpSendError("Unable to send OTP, please try again");
|
||||
}
|
||||
} catch {
|
||||
setOtpSendError("Unable to connect, please try again");
|
||||
} finally {
|
||||
setOtpSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "otp") {
|
||||
sendOtp();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// Validate Vietnamese phone number
|
||||
const validatePhone = (phoneNumber: string): boolean => {
|
||||
@@ -57,86 +28,43 @@ export default function RegisterPage() {
|
||||
return phoneRegex.test(phoneNumber);
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handlePhoneSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
setErrors({ ...errors, phone: "Please enter your phone number" });
|
||||
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({
|
||||
...errors,
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
// Move to OTP step
|
||||
setStep("otp");
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customer/quick_signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStep("otp");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const phoneErrorMap: Record<string, string> = {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
|
||||
setErrors({ phone: msg, otp: "" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "Unable to connect, please try again", otp: "" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleOtpSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ ...errors, otp: "Please enter your OTP code" });
|
||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customer/quick_login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const msg =
|
||||
errorCode === "InvalidOTP"
|
||||
? "Incorrect or expired OTP code"
|
||||
: "An error occurred, please try again";
|
||||
setErrors({ phone: "", otp: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "", otp: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (otp !== DEMO_OTP) {
|
||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete registration
|
||||
completeRegistration(phone);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleBackToPhone = () => {
|
||||
@@ -166,8 +94,8 @@ export default function RegisterPage() {
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone"
|
||||
? "Create a customer account"
|
||||
: "Verify your phone number"}
|
||||
? "Đăng ký tài khoản khách hàng"
|
||||
: "Xác thực số điện thoại"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +131,7 @@ export default function RegisterPage() {
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Phone number
|
||||
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>
|
||||
@@ -216,8 +144,7 @@ export default function RegisterPage() {
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
|
||||
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 && (
|
||||
@@ -227,7 +154,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||
Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -239,16 +166,8 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
Tiếp tục
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
@@ -256,7 +175,7 @@ export default function RegisterPage() {
|
||||
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"
|
||||
>
|
||||
Back to sign in
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -269,16 +188,7 @@ export default function RegisterPage() {
|
||||
<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>
|
||||
{otpSending ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||
Sending OTP to <strong>{phone}</strong>...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
OTP code sent to <strong>{phone}</strong>
|
||||
</>
|
||||
)}
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Demo OTP:{" "}
|
||||
@@ -287,12 +197,6 @@ export default function RegisterPage() {
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
{otpSendError && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{otpSendError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
@@ -300,7 +204,7 @@ export default function RegisterPage() {
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
OTP code
|
||||
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>
|
||||
@@ -312,10 +216,9 @@ export default function RegisterPage() {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ ...errors, otp: "" });
|
||||
}}
|
||||
placeholder="Enter OTP code"
|
||||
placeholder="Nhập mã OTP"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
|
||||
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 && (
|
||||
@@ -334,16 +237,8 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading || otpSending}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Complete registration"
|
||||
)}
|
||||
Hoàn tất đăng ký
|
||||
</Button>
|
||||
|
||||
{/* Back Button */}
|
||||
@@ -352,28 +247,19 @@ export default function RegisterPage() {
|
||||
onClick={handleBackToPhone}
|
||||
size="lg"
|
||||
style="login"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Change phone number
|
||||
Thay đổi số điện thoại
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP */}
|
||||
{/* Resend OTP (disabled in demo) */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendOtp}
|
||||
disabled={otpSending || isLoading}
|
||||
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
|
||||
disabled
|
||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
||||
>
|
||||
{otpSending ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||
Resending...
|
||||
</>
|
||||
) : (
|
||||
"Resend OTP code"
|
||||
)}
|
||||
Gửi lại mã OTP (60s)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -28,10 +28,10 @@ import { useMemo, useState } from "react";
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
||||
day: "By day",
|
||||
week: "By week",
|
||||
month: "By month",
|
||||
year: "By year",
|
||||
day: "Theo ngày",
|
||||
week: "Theo tuần",
|
||||
month: "Theo tháng",
|
||||
year: "Theo năm",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
@@ -67,7 +67,7 @@ const CHART_META: Record<ChartType, { icon: string; label: string }> = {
|
||||
function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
label = "Category:",
|
||||
label = "Danh mục:",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -78,12 +78,12 @@ function CategorySelect({
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-(--color-text-muted)">{label}</label>
|
||||
<select
|
||||
title="Category"
|
||||
title="Danh mục"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="all">Tất cả</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -181,7 +181,7 @@ export default function AnalyticsPage() {
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-foreground text-lg leading-tight font-bold">
|
||||
Financial Statistics & Analytics
|
||||
Thống kê & Phân tích tài chính
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Financial Analytics Dashboard
|
||||
@@ -205,7 +205,7 @@ export default function AnalyticsPage() {
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
title="Select period"
|
||||
title="Chọn kỳ"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
|
||||
@@ -226,12 +226,12 @@ export default function AnalyticsPage() {
|
||||
{/* ── Summary Cards ── */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Overview
|
||||
Tổng quan
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-sack-dollar"
|
||||
title="Total Revenue"
|
||||
title="Tổng doanh thu"
|
||||
value={formatCurrency(totalRevenue)}
|
||||
subtitle={PERIOD_LABELS[period]}
|
||||
change={revComp.change}
|
||||
@@ -240,27 +240,27 @@ export default function AnalyticsPage() {
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-receipt"
|
||||
title="Total Orders"
|
||||
title="Số đơn hàng"
|
||||
value={totalOrders.toLocaleString()}
|
||||
subtitle="Total orders in period"
|
||||
subtitle="Tổng đơn trong kỳ"
|
||||
change={ordComp.change}
|
||||
changePercent={ordComp.changePercent}
|
||||
isPositive={ordComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-circle-dollar-to-slot"
|
||||
title="Total Profit"
|
||||
title="Tổng lợi nhuận"
|
||||
value={formatCurrency(totalProfit)}
|
||||
subtitle="Estimated from sales data"
|
||||
subtitle="Ước tính từ dữ liệu bán hàng"
|
||||
change={proComp.change}
|
||||
changePercent={proComp.changePercent}
|
||||
isPositive={proComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-basket-shopping"
|
||||
title="Avg. Order Value"
|
||||
title="Giá trị đơn TB"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
subtitle="Revenue / number of orders"
|
||||
subtitle="Doanh thu / số đơn hàng"
|
||||
change={0}
|
||||
changePercent={0}
|
||||
isPositive={true}
|
||||
@@ -273,7 +273,7 @@ export default function AnalyticsPage() {
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
|
||||
Revenue Chart
|
||||
Biểu đồ doanh thu
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{CHART_TYPES.map((t) => (
|
||||
@@ -298,7 +298,8 @@ export default function AnalyticsPage() {
|
||||
{activeChart === "line" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Revenue over time — {PERIOD_LABELS[period]}
|
||||
Doanh thu theo thời gian — {PERIOD_LABELS[period]}
|
||||
<span className="ml-2 opacity-70">(tr = triệu đồng VND)</span>
|
||||
</p>
|
||||
<LineChart data={revenueData} height={220} />
|
||||
</>
|
||||
@@ -306,7 +307,8 @@ export default function AnalyticsPage() {
|
||||
{activeChart === "bar" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Comparing revenue of the first and second half of the current period
|
||||
So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại
|
||||
<span className="ml-2 opacity-70">(tr = triệu đồng VND)</span>
|
||||
</p>
|
||||
<BarChart
|
||||
current={barCurrent}
|
||||
@@ -318,7 +320,7 @@ export default function AnalyticsPage() {
|
||||
{activeChart === "pie" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Revenue share by product category
|
||||
Tỷ trọng doanh thu theo danh mục sản phẩm
|
||||
</p>
|
||||
<PieChart data={pieData} />
|
||||
</>
|
||||
@@ -330,7 +332,7 @@ export default function AnalyticsPage() {
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
||||
Top best-selling products
|
||||
Top sản phẩm bán chạy
|
||||
</h2>
|
||||
<CategorySelect
|
||||
value={categoryFilter}
|
||||
@@ -353,7 +355,7 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<span className="text-(--color-text-muted) tabular-nums">
|
||||
{p.unitsSold} cups
|
||||
{p.unitsSold} ly
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary) tabular-nums">
|
||||
{formatCurrency(p.revenue)}
|
||||
@@ -377,17 +379,17 @@ export default function AnalyticsPage() {
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
||||
Detailed product analytics
|
||||
Phân tích chi tiết sản phẩm
|
||||
</h2>
|
||||
<CategorySelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
label="Filter category:"
|
||||
label="Lọc danh mục:"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Click column headers to sort. Showing {filteredSales.length}{" "}
|
||||
products.
|
||||
Click vào tiêu đề cột để sắp xếp. Hiển thị {filteredSales.length}{" "}
|
||||
sản phẩm.
|
||||
</p>
|
||||
<ProductTable data={filteredSales} />
|
||||
|
||||
@@ -395,7 +397,7 @@ export default function AnalyticsPage() {
|
||||
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Total revenue:{" "}
|
||||
Tổng doanh thu:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatCurrencyFull(filteredRevenue)}
|
||||
@@ -403,7 +405,7 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Total profit:{" "}
|
||||
Tổng lợi nhuận:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrencyFull(filteredProfit)}
|
||||
@@ -411,15 +413,15 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Total units:{" "}
|
||||
Tổng sản lượng:{" "}
|
||||
</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{filteredUnits.toLocaleString()} cups
|
||||
{filteredUnits.toLocaleString()} ly
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Avg. profit margin:{" "}
|
||||
Biên LN trung bình:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-yellow-700">
|
||||
{avgMargin.toFixed(1)}%
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
MenuItemsTab,
|
||||
ProductsTab,
|
||||
} from "@/components/organisms/manager";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
@@ -18,7 +17,7 @@ export default function ManagerPage() {
|
||||
const tabs = [
|
||||
{
|
||||
id: "products" as const,
|
||||
label: "Menu",
|
||||
label: "Thực đơn",
|
||||
icon: "fa-solid fa-utensils",
|
||||
count: products.length,
|
||||
},
|
||||
@@ -30,16 +29,10 @@ export default function ManagerPage() {
|
||||
},
|
||||
{
|
||||
id: "categories" as const,
|
||||
label: "Categories",
|
||||
label: "Danh mục",
|
||||
icon: "fa-solid fa-tags",
|
||||
count: categories.length,
|
||||
},
|
||||
{
|
||||
id: "menu-items" as const,
|
||||
label: "Menu",
|
||||
icon: "fa-solid fa-bowl-food",
|
||||
count: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -58,13 +51,13 @@ export default function ManagerPage() {
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Menu Management
|
||||
Quản lý thực đơn
|
||||
</p>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||
@@ -72,41 +65,39 @@ export default function ManagerPage() {
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1 text-left">{tab.label}</span>
|
||||
{tab.count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
activeTab === tab.id
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
activeTab === tab.id
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Analytics
|
||||
Phân tích
|
||||
</p>
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark) focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Finance</span>
|
||||
<span className="flex-1 text-left">Tài chính</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
New
|
||||
Mới
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark) focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Shifts</span>
|
||||
<span className="flex-1 text-left">Ca làm</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
New
|
||||
Mới
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -119,9 +110,9 @@ export default function ManagerPage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Manager"}
|
||||
{user?.name ?? "Quản lý"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Quản lý quán</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2 px-1">
|
||||
@@ -130,14 +121,14 @@ export default function ManagerPage() {
|
||||
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Home
|
||||
Trang chủ
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50 focus-visible:ring-2 focus-visible:ring-red-400/50 focus-visible:outline-none"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Logout
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,18 +139,16 @@ export default function ManagerPage() {
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-foreground text-lg font-bold">
|
||||
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
|
||||
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"}
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Manage{" "}
|
||||
Quản lý{" "}
|
||||
{activeTab === "products"
|
||||
? "menu items"
|
||||
? "thực đơn"
|
||||
: activeTab === "combos"
|
||||
? "combos"
|
||||
: activeTab === "categories"
|
||||
? "categories"
|
||||
: "menu"}{" "}
|
||||
for your store
|
||||
? "combo"
|
||||
: "danh mục"}{" "}
|
||||
của quán
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +158,7 @@ export default function ManagerPage() {
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
@@ -184,7 +173,7 @@ export default function ManagerPage() {
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span className="hidden sm:inline">Finance</span>
|
||||
<span className="hidden sm:inline">Tài chính</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -195,14 +184,14 @@ export default function ManagerPage() {
|
||||
className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
Financial Analytics
|
||||
Thống kê tài chính
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Home
|
||||
Trang chủ
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@@ -211,7 +200,6 @@ export default function ManagerPage() {
|
||||
{activeTab === "products" && <ProductsTab />}
|
||||
{activeTab === "combos" && <CombosTab />}
|
||||
{activeTab === "categories" && <CategoriesTab />}
|
||||
{activeTab === "menu-items" && <MenuItemsTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,18 +12,18 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
];
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
@@ -95,7 +95,7 @@ export default function StaffSchedulePage() {
|
||||
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-bold">Work Schedule</p>
|
||||
<p className="text-foreground text-sm font-bold">Lịch làm việc</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
</p>
|
||||
@@ -105,7 +105,7 @@ export default function StaffSchedulePage() {
|
||||
{/* View toggle */}
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
View
|
||||
Chế độ xem
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -117,7 +117,7 @@ export default function StaffSchedulePage() {
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-table-columns w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Weekly</span>
|
||||
<span className="flex-1 text-left">Theo tuần</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -129,13 +129,13 @@ export default function StaffSchedulePage() {
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-calendar w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Monthly</span>
|
||||
<span className="flex-1 text-left">Theo tháng</span>
|
||||
</button>
|
||||
|
||||
{/* Quick nav */}
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Navigation
|
||||
Điều hướng
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,7 +143,7 @@ export default function StaffSchedulePage() {
|
||||
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Today</span>
|
||||
<span className="flex-1 text-left">Hôm nay</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function StaffSchedulePage() {
|
||||
{isManager && (
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Management
|
||||
Quản lý
|
||||
</p>
|
||||
<Link
|
||||
href="/manager"
|
||||
@@ -167,7 +167,7 @@ export default function StaffSchedulePage() {
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<div className="rounded-xl bg-(--color-primary)/5 p-3">
|
||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||
Weekly Budget
|
||||
Ngân sách tuần
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-(--color-primary)">
|
||||
{weeklyBudget.toLocaleString("vi-VN")}
|
||||
@@ -187,10 +187,10 @@ export default function StaffSchedulePage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Staff"}
|
||||
{user?.name ?? "Nhân viên"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
{isManager ? "Quản lý" : "Nhân viên"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +200,7 @@ export default function StaffSchedulePage() {
|
||||
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Home
|
||||
Trang chủ
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -208,7 +208,7 @@ export default function StaffSchedulePage() {
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Logout
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export default function StaffSchedulePage() {
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
|
||||
<div>
|
||||
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||
Register Shift
|
||||
Đăng ký ca làm
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{view === "week"
|
||||
@@ -241,7 +241,7 @@ export default function StaffSchedulePage() {
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Week
|
||||
Tuần
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -252,14 +252,14 @@ export default function StaffSchedulePage() {
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Month
|
||||
Tháng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<button
|
||||
title="Previous"
|
||||
title="Về trước"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
@@ -271,10 +271,10 @@ export default function StaffSchedulePage() {
|
||||
onClick={goToToday}
|
||||
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
>
|
||||
Today
|
||||
Hôm nay
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
title="Tiếp theo"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
@@ -294,14 +294,14 @@ export default function StaffSchedulePage() {
|
||||
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Create Shift
|
||||
Tạo ca
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile nav */}
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
title="Previous"
|
||||
title="Về trước"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
@@ -309,7 +309,7 @@ export default function StaffSchedulePage() {
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
title="Tiếp theo"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
@@ -353,7 +353,7 @@ export default function StaffSchedulePage() {
|
||||
{/* Mobile FAB for manager */}
|
||||
{isManager && (
|
||||
<button
|
||||
title="Create Shift"
|
||||
title="Tạo ca"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateDate(undefined);
|
||||
|
||||
+109
-18
@@ -24,12 +24,16 @@ app/
|
||||
│ ├── layout.tsx # Feed layout
|
||||
│ └── feed/
|
||||
│ └── page.tsx # Trang khám phá quán (/feed)
|
||||
└── (manager)/
|
||||
├── layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||
└── manager/
|
||||
├── page.tsx # Manager Dashboard - quản lý thực đơn (/manager)
|
||||
└── analytics/
|
||||
└── page.tsx # Financial Analytics (/manager/analytics)
|
||||
├── (manager)/
|
||||
│ ├── layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||
│ └── manager/
|
||||
│ ├── page.tsx # Manager Dashboard - quản lý thực đơn (/manager)
|
||||
│ └── analytics/
|
||||
│ └── page.tsx # Financial Analytics (/manager/analytics)
|
||||
└── (staff)/
|
||||
└── staff/
|
||||
└── schedule/
|
||||
└── page.tsx # Staff Shift Schedule (/staff/schedule)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -241,7 +245,7 @@ registration flow. Two-step process: phone verification → account creation.
|
||||
|
||||
**Route:** `/payment` **Type:** Client component **Description:**
|
||||
Payment/checkout page. Shows cart items in a table with quantity controls and a
|
||||
summary aside with payment actions.
|
||||
summary aside (PaymentSummaryCard) with payment actions and modal flows.
|
||||
|
||||
#### Key Features
|
||||
|
||||
@@ -251,27 +255,46 @@ summary aside with payment actions.
|
||||
- Empty state message when cart is empty
|
||||
- Horizontal scroll on small screens (min-w-190)
|
||||
|
||||
- **Invoice Aside:**
|
||||
- **PaymentSummaryCard (Invoice Aside):**
|
||||
- Sticky on desktop (top offset = header height + 1rem)
|
||||
- Shows total price
|
||||
- Payment buttons: Tiền mặt, QR Code (UI-only; trigger review modal for
|
||||
customers)
|
||||
- Shows all cart items with subtotals and grand total
|
||||
- Payment method buttons: Tiền mặt (Cash), QR Code
|
||||
- **"Đánh giá" button** — only visible when `user.role === "customer"`; opens
|
||||
ReviewModal
|
||||
- **"Quay về" button** — links back to `/`; spans full width when review
|
||||
button is absent
|
||||
|
||||
- **Review Modal (ReviewModal):**
|
||||
- Opened when a customer clicks "Đánh giá" OR clicks a payment button (Tiền
|
||||
mặt/QR)
|
||||
- Closed via "Quay lại" button, backdrop click, or after submitting and
|
||||
closing
|
||||
#### Payment Modal Flow
|
||||
|
||||
- **Cash Payment (CashPaymentModal):**
|
||||
- Opened when user clicks "Tiền mặt"
|
||||
- Input: cash received amount
|
||||
- Shows change due or insufficient cash error inline
|
||||
- Displays invoice item list
|
||||
- Confirm → opens PaymentSuccessModal
|
||||
|
||||
- **QR Payment (QRPaymentModal):**
|
||||
- Opened when user clicks "QR Code"
|
||||
- Generates QR code via `qrcode.react` (`QRCodeSVG` component)
|
||||
- 5-second countdown timer — auto-confirms on expiry
|
||||
- Displays invoice item list
|
||||
- Confirm → opens PaymentSuccessModal
|
||||
|
||||
- **PaymentSuccessModal:**
|
||||
- Shown after confirming Cash or QR payment
|
||||
- Displays success confirmation message
|
||||
- Clears cart via `clearCart()` from CartContext on close/confirm
|
||||
|
||||
- **ReviewModal:**
|
||||
- Opened when a customer clicks "Đánh giá"
|
||||
- 5-star rating + textarea for comment
|
||||
- Closed via "Quay lại" button, backdrop click, or after submitting
|
||||
- See `components/ReviewModal.tsx` for full documentation
|
||||
|
||||
#### Context Usage
|
||||
|
||||
- **useCart()** — items, totalPrice, increaseQty, decreaseQty, removeFromCart,
|
||||
setQuantity
|
||||
setQuantity, clearCart
|
||||
- **useAuth()** — user (to check `user.role === "customer"` for review button
|
||||
visibility)
|
||||
|
||||
@@ -279,6 +302,9 @@ summary aside with payment actions.
|
||||
|
||||
```tsx
|
||||
isReviewOpen: boolean; // Controls ReviewModal visibility
|
||||
isCashModalOpen: boolean; // Controls CashPaymentModal visibility
|
||||
isQRModalOpen: boolean; // Controls QRPaymentModal visibility
|
||||
isSuccessModalOpen: boolean; // Controls PaymentSuccessModal visibility
|
||||
isCustomer: boolean; // Derived from user.role === "customer"
|
||||
```
|
||||
|
||||
@@ -502,13 +528,78 @@ categoryFilter: string; // category id or "all"
|
||||
|
||||
---
|
||||
|
||||
## (staff) Route Group
|
||||
|
||||
### Layout
|
||||
|
||||
**Description:** Route group for staff-specific pages. Uses `StaffLayout` from
|
||||
`components/templates/staff-layout/` which provides the staff navigation shell.
|
||||
|
||||
---
|
||||
|
||||
### Staff Shift Schedule (app/(staff)/staff/schedule/page.tsx)
|
||||
|
||||
**Route:** `/staff/schedule` **Type:** Client component **Description:** Weekly
|
||||
and monthly shift schedule management for staff members.
|
||||
|
||||
#### Key Features
|
||||
|
||||
- **Weekly Schedule View (WeeklySchedule.tsx):**
|
||||
- Grid-based view of shifts for each day of the current week
|
||||
- Shows shift time, role, and department per cell
|
||||
- Click a shift to open ShiftDetailModal
|
||||
|
||||
- **Monthly Calendar View (MonthlyCalendar.tsx):**
|
||||
- Full month calendar grid
|
||||
- Shifts rendered as colored chips per day
|
||||
- Navigate between months
|
||||
|
||||
- **Mobile Shift View (MobileShiftView.tsx):**
|
||||
- Optimized list-based view for small screens
|
||||
- Shows upcoming shifts for the current/selected date
|
||||
|
||||
- **ShiftCreateModal:**
|
||||
- Form to create a new shift: date, start/end time, role, department
|
||||
- Validates time range before submitting
|
||||
|
||||
- **ShiftDetailModal:**
|
||||
- Shows full details of a selected shift
|
||||
- Options to edit or delete the shift
|
||||
|
||||
- **Department Filter:**
|
||||
- Dropdown to filter schedule view by department
|
||||
|
||||
#### Context Usage
|
||||
|
||||
- **useShift()** (lib/shift-context.tsx) — shifts, createShift, updateShift,
|
||||
deleteShift, selectedDate, setSelectedDate
|
||||
|
||||
#### State Management
|
||||
|
||||
```tsx
|
||||
activeView: "weekly" | "monthly"; // Toggle between calendar views
|
||||
isCreateModalOpen: boolean; // Controls ShiftCreateModal
|
||||
selectedShift: Shift | null; // Shift passed to ShiftDetailModal
|
||||
departmentFilter: string; // Currently selected department
|
||||
```
|
||||
|
||||
#### Responsive Behavior
|
||||
|
||||
- **Mobile (<768px):** MobileShiftView displayed instead of grid calendars
|
||||
- **Tablet/Desktop (768px+):** WeeklySchedule or MonthlyCalendar displayed
|
||||
- **Department filter:** Sticky top bar on all screen sizes
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [x] Payment page implementation
|
||||
- [x] Customer review modal (ReviewModal) with 5-star rating + textarea
|
||||
- [x] Manager dashboard (menu management)
|
||||
- [x] Financial Analytics dashboard (charts, profit analysis)
|
||||
- [x] Staff shift schedule (weekly/monthly views, CRUD, department filter)
|
||||
- [x] Cash and QR payment modals with invoice display
|
||||
- [x] Image upload for products in manager dashboard
|
||||
- [ ] Order history/tracking page
|
||||
- [ ] User profile page
|
||||
- [ ] Cart checkout flow
|
||||
- [ ] Real backend API integration
|
||||
|
||||
+180
-7
@@ -7,14 +7,31 @@
|
||||
|
||||
```
|
||||
components/
|
||||
├── atoms/ # Basic building blocks (Button, Input, Text, Badge...)
|
||||
├── molecules/ # Groups of atoms (ProductCard, SearchBar...)
|
||||
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal, analytics charts...)
|
||||
└── templates/ # Page layout structures (MainLayout, AuthLayout...)
|
||||
├── atoms/ # Basic building blocks — 14 atoms (Button, Input, Text, Badge...) — see atoms/ATOMS.md
|
||||
├── molecules/ # Groups of atoms — 5 molecules (ProductCard, ShopCard, ShiftCard, SearchBar, PaymentSummaryCard)
|
||||
├── organisms/ # Complex UI sections — 27 organisms (analytics, cart, forms, manager, modals, navigation, grids, shift-schedule)
|
||||
└── templates/ # Page layout structures — 5 templates (MainLayout, AuthLayout, FeedLayout, ManagerLayout, StaffLayout)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ATOMS
|
||||
|
||||
See [`atoms/ATOMS.md`](atoms/ATOMS.md) for full atom documentation (14 atoms).
|
||||
|
||||
**Summary of atom groups:**
|
||||
|
||||
| Folder | Atoms |
|
||||
| ----------- | -------------------------------------------- |
|
||||
| buttons/ | Button, IconButton |
|
||||
| inputs/ | TextInput, SearchInput, Textarea, LoginInput |
|
||||
| typography/ | Heading, Text, Caption |
|
||||
| badges/ | Badge, PriceBadge |
|
||||
| dividers/ | Divider |
|
||||
| errors/ | ErrorMessageLogin |
|
||||
|
||||
---
|
||||
|
||||
## MOLECULES
|
||||
|
||||
### ProductCard (`molecules/cards/ProductCard.tsx`)
|
||||
@@ -25,16 +42,58 @@ parent grid (w-full).
|
||||
|
||||
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
|
||||
|
||||
---
|
||||
|
||||
### ShopCard (`molecules/cards/ShopCard.tsx`)
|
||||
|
||||
**Description:** Shop discovery card. Displays shop image, name, address, and a
|
||||
link to the menu.
|
||||
|
||||
**Atomic level:** Molecule
|
||||
|
||||
---
|
||||
|
||||
### ShiftCard (`molecules/cards/ShiftCard.tsx`)
|
||||
|
||||
**Description:** Card displaying a single shift slot. Shows start/end time,
|
||||
duration, wage, status badge, and registered staff list. Supports a compact mode
|
||||
for use in calendar grids.
|
||||
|
||||
**Atomic level:** Molecule
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface ShiftCardProps {
|
||||
shift: ShiftSlot; // Shift slot data (status, times, staff, etc.)
|
||||
compact?: boolean; // Compact mode for calendar cells (default: false)
|
||||
onClick?: (shift: ShiftSlot) => void; // Click handler
|
||||
}
|
||||
```
|
||||
|
||||
**Status variants:** `available` (blue), `registered` (dark blue),
|
||||
`approved_leave` (purple), `absent` (red)
|
||||
|
||||
**Used in:** `WeeklySchedule`, `MobileShiftView`, `MonthlyCalendar`
|
||||
|
||||
---
|
||||
|
||||
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
|
||||
|
||||
**Description:** Search input with clear button. Controlled component — value
|
||||
and onChange come from parent.
|
||||
|
||||
**Atomic level:** Molecule (wraps SearchInput atom)
|
||||
|
||||
---
|
||||
|
||||
### PaymentSummaryCard (`molecules/cards/PaymentSummaryCard.tsx`)
|
||||
|
||||
**Description:** Summary card displayed on the payment page. Shows cart items,
|
||||
subtotal, and payment method selection. Triggers cash or QR payment modals.
|
||||
|
||||
**Atomic level:** Molecule
|
||||
|
||||
---
|
||||
|
||||
## ORGANISMS
|
||||
@@ -44,25 +103,35 @@ and onChange come from parent.
|
||||
**Description:** Left sidebar with collapsible category filter. Sticky below
|
||||
header, full viewport height minus header.
|
||||
|
||||
---
|
||||
|
||||
### CartFab (`organisms/cart/CartFab.tsx`)
|
||||
|
||||
**Description:** Floating Action Button displaying cart item count. Links to
|
||||
/payment page.
|
||||
|
||||
---
|
||||
|
||||
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
|
||||
|
||||
**Description:** Full product grid organism with mobile category tabs,
|
||||
filtering, and empty state. Uses useCart and useMenu contexts.
|
||||
|
||||
---
|
||||
|
||||
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
|
||||
|
||||
**Description:** Responsive grid of ShopCard molecules for the feed/discovery
|
||||
page.
|
||||
|
||||
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
|
||||
---
|
||||
|
||||
**Description:** Modal for customer reviews. Shows 5-star rating and textarea.
|
||||
After submission, displays thank-you message.
|
||||
### LoginForm (`organisms/forms/LoginForm.tsx`)
|
||||
|
||||
**Description:** Login form organism. Uses LoginInput and ErrorMessageLogin
|
||||
atoms. Handles admin/staff/customer login flows via useAuth context.
|
||||
|
||||
---
|
||||
|
||||
### Analytics Charts (`organisms/analytics/`)
|
||||
|
||||
@@ -81,6 +150,101 @@ dashboard. All components are interactive with hover tooltips.
|
||||
|
||||
---
|
||||
|
||||
### Manager Organisms (`organisms/manager/`)
|
||||
|
||||
Components for the manager dashboard product/category/combo management.
|
||||
|
||||
| Component | File | Description |
|
||||
| ---------------- | ----------------- | ------------------------------------------------------- |
|
||||
| CategoriesTab | CategoriesTab.tsx | Category list with add/edit/delete actions |
|
||||
| CategoryModal | CategoryModal.tsx | Modal form to create/edit a category |
|
||||
| ComboModal | ComboModal.tsx | Modal form to create/edit a combo |
|
||||
| CombosTab | CombosTab.tsx | Combo list with add/edit/delete actions |
|
||||
| DeleteConfirm | DeleteConfirm.tsx | Generic delete confirmation dialog |
|
||||
| ProductModal | ProductModal.tsx | Modal form to create/edit a product (with image upload) |
|
||||
| ProductsTab | ProductsTab.tsx | Product list with add/edit/delete actions |
|
||||
| StatusBadge | StatusBadge.tsx | Badge showing active/inactive status |
|
||||
| Manager.types.ts | Manager.types.ts | Shared TypeScript interfaces for manager components |
|
||||
|
||||
---
|
||||
|
||||
### Modals (`organisms/modals/`)
|
||||
|
||||
| Component | File | Description |
|
||||
| ------------------- | ----------------------- | --------------------------------------------------------- |
|
||||
| ReviewModal | ReviewModal.tsx | 5-star rating and textarea review submission modal |
|
||||
| CashPaymentModal | CashPaymentModal.tsx | Cash payment — input received amount, calculate change |
|
||||
| QRPaymentModal | QRPaymentModal.tsx | QR code payment — generates QR, 5s countdown auto-confirm |
|
||||
| PaymentSuccessModal | PaymentSuccessModal.tsx | Success screen — auto-redirects to home after 1 second |
|
||||
| Modal.types.ts | Modal.types.ts | Shared TypeScript interfaces for all modal components |
|
||||
|
||||
#### CashPaymentModal
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface CashPaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
items: InvoiceItem[]; // { name, quantity, price }[]
|
||||
totalAmount: number;
|
||||
}
|
||||
```
|
||||
|
||||
- ARIA: `role="dialog"`, `aria-modal="true"`,
|
||||
`aria-labelledby="cash-modal-title"`
|
||||
- Shows invoice list, cash received input, change due calculation, insufficient
|
||||
cash error
|
||||
|
||||
#### QRPaymentModal
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface QRPaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
items: InvoiceItem[];
|
||||
totalAmount: number;
|
||||
}
|
||||
```
|
||||
|
||||
- Uses `qrcode.react` (`QRCodeSVG`) to render a QR code
|
||||
- 5-second countdown timer — auto-calls `onConfirm` when it reaches 0
|
||||
- ARIA: `role="dialog"`, `aria-modal="true"`, `aria-labelledby="qr-modal-title"`
|
||||
|
||||
#### PaymentSuccessModal
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface PaymentSuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
- Auto-redirects to `/` after 1 second via `useRouter`
|
||||
- ARIA: `role="dialog"`, `aria-modal="true"`
|
||||
|
||||
---
|
||||
|
||||
### Shift Schedule (`organisms/shift-schedule/`)
|
||||
|
||||
Components for the staff shift scheduling feature.
|
||||
|
||||
| Component | File | Description |
|
||||
| ---------------- | -------------------- | ----------------------------------------------------- |
|
||||
| WeeklySchedule | WeeklySchedule.tsx | Weekly calendar grid showing shifts per day |
|
||||
| MonthlyCalendar | MonthlyCalendar.tsx | Monthly calendar view with shift indicators per day |
|
||||
| MobileShiftView | MobileShiftView.tsx | Mobile-optimized list/card view of shifts |
|
||||
| ShiftCreateModal | ShiftCreateModal.tsx | Form modal for registering for or creating a shift |
|
||||
| ShiftDetailModal | ShiftDetailModal.tsx | Detail view of a shift with its registered staff list |
|
||||
|
||||
---
|
||||
|
||||
## TEMPLATES
|
||||
|
||||
### MainLayout (`templates/main-layout/MainLayout.tsx`)
|
||||
@@ -102,6 +266,11 @@ login/register pages.
|
||||
**Description:** Manager layout template — auth guard + ManagerProvider. Used by
|
||||
the manager route group.
|
||||
|
||||
### StaffLayout (`templates/staff-layout/`)
|
||||
|
||||
**Description:** Staff layout template — wraps staff-facing pages (shift
|
||||
schedule, etc.). Used by the staff route group.
|
||||
|
||||
---
|
||||
|
||||
## Contexts Documentation
|
||||
@@ -125,3 +294,7 @@ mobile menu and CategorySidebar selection.
|
||||
|
||||
Manages menu CRUD state (products, combos, categories) for the manager
|
||||
dashboard.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-04-18
|
||||
|
||||
+131
-35
@@ -1,7 +1,7 @@
|
||||
# Atoms Components Library
|
||||
|
||||
**Status:** ✅ Phase 1 Implementation Complete **Created:** 2026-04-03
|
||||
**Foundation:** Atomic Design Pattern
|
||||
**Status:** ✅ Complete — 14 atoms **Created:** 2026-04-03 **Foundation:**
|
||||
Atomic Design Pattern
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ potentially other atoms).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Atoms Created
|
||||
## 🎯 Atoms Created (14 total)
|
||||
|
||||
### Buttons (`buttons/`)
|
||||
|
||||
@@ -181,6 +181,58 @@ import { Textarea } from "@/components/atoms";
|
||||
|
||||
---
|
||||
|
||||
#### **LoginInput.tsx**
|
||||
|
||||
Specialized input for the login form. Supports username/phone and password
|
||||
fields. Password fields include a built-in show/hide toggle button. Displays a
|
||||
user icon on desktop viewports.
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string; // Field label (rendered as <label>)
|
||||
type: string; // Input type — "text" | "password"
|
||||
name: string; // Input id and name (used for label htmlFor)
|
||||
value: string; // Controlled value
|
||||
errors?: string; // Error string — adds red border when set
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||
|
||||
<LoginInput
|
||||
label="Tên đăng nhập"
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
errors={errors.username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<LoginInput
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
errors={errors.password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Password type shows a show/hide toggle button (ARIA-labelled)
|
||||
- Desktop: renders a `fa-user` icon at left via `lg:block`
|
||||
- Error state: red border via `border-red-400` when `errors` is set
|
||||
- Used in: `LoginForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Typography (`typography/`)
|
||||
|
||||
#### **Heading.tsx**
|
||||
@@ -346,6 +398,50 @@ import { Divider } from "@/components/atoms";
|
||||
|
||||
---
|
||||
|
||||
### Errors (`errors/`)
|
||||
|
||||
#### **ErrorMessageLogin.tsx**
|
||||
|
||||
Displays error messages in the login form. Two visual styles controlled by the
|
||||
`type` prop: a prominent alert box (`primary`) and a compact inline message
|
||||
(`secondary`). Both include a `fa-circle-exclamation` icon.
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface ErrorMessageLoginProps {
|
||||
message: string; // Error message text to display
|
||||
type?: string; // "primary" (default) | "secondary"
|
||||
}
|
||||
```
|
||||
|
||||
**Variants:**
|
||||
|
||||
- `primary` (default): Rounded alert box — red border, red-50 background,
|
||||
`text-red-600`, `text-sm`. Used for form-level errors (e.g. wrong password).
|
||||
- `secondary`: Inline paragraph — `text-red-500`, `text-xs`. Used for
|
||||
field-level errors below individual inputs.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
|
||||
{
|
||||
/* Form-level error */
|
||||
}
|
||||
<ErrorMessageLogin message="Tên đăng nhập hoặc mật khẩu không đúng" />;
|
||||
|
||||
{
|
||||
/* Field-level inline error */
|
||||
}
|
||||
<ErrorMessageLogin message="Vui lòng nhập mật khẩu" type="secondary" />;
|
||||
```
|
||||
|
||||
**Used in:** `LoginForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Theming
|
||||
|
||||
All atoms use CSS variables for consistent theming:
|
||||
@@ -378,7 +474,8 @@ Change one variable in `globals.css` to update all atoms across the app.
|
||||
import { Badge, PriceBadge } from "@/components/atoms/badges";
|
||||
import { Button } from "@/components/atoms/buttons";
|
||||
import { Divider } from "@/components/atoms/dividers";
|
||||
import { SearchInput, TextInput } from "@/components/atoms/inputs";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { LoginInput, SearchInput, TextInput } from "@/components/atoms/inputs";
|
||||
import { Caption, Heading, Text } from "@/components/atoms/typography";
|
||||
```
|
||||
|
||||
@@ -400,6 +497,9 @@ import {
|
||||
} from "@/components/atoms";
|
||||
```
|
||||
|
||||
> Note: `ErrorMessageLogin` and `LoginInput` are imported directly from their
|
||||
> subfolders as they are auth-specific and not re-exported from the root barrel.
|
||||
|
||||
### Type Imports
|
||||
|
||||
```tsx
|
||||
@@ -483,50 +583,46 @@ All atoms include:
|
||||
|
||||
```
|
||||
components/atoms/
|
||||
├── buttons/
|
||||
│ ├── Button.tsx
|
||||
│ ├── Button.types.ts
|
||||
│ ├── IconButton.tsx
|
||||
│ └── index.ts
|
||||
├── inputs/
|
||||
│ ├── TextInput.tsx
|
||||
│ ├── SearchInput.tsx
|
||||
│ ├── Textarea.tsx
|
||||
│ ├── Input.types.ts
|
||||
│ └── index.ts
|
||||
├── typography/
|
||||
│ ├── Heading.tsx
|
||||
│ ├── Text.tsx
|
||||
│ ├── Caption.tsx
|
||||
│ ├── Typography.types.ts
|
||||
│ └── index.ts
|
||||
├── badges/
|
||||
│ ├── Badge.tsx
|
||||
│ ├── Badge.types.ts
|
||||
│ ├── PriceBadge.tsx
|
||||
│ └── index.ts
|
||||
├── buttons/
|
||||
│ ├── Button.tsx
|
||||
│ ├── Button.types.ts
|
||||
│ ├── IconButton.tsx
|
||||
│ └── index.ts
|
||||
├── dividers/
|
||||
│ ├── Divider.tsx
|
||||
│ └── index.ts
|
||||
├── errors/ ← auth error display
|
||||
│ ├── Error.types.ts
|
||||
│ └── ErrorMessageLogin.tsx
|
||||
├── inputs/
|
||||
│ ├── Input.types.ts
|
||||
│ ├── LoginInput.tsx ← auth-specific input
|
||||
│ ├── SearchInput.tsx
|
||||
│ ├── Textarea.tsx
|
||||
│ ├── TextInput.tsx
|
||||
│ └── index.ts
|
||||
├── typography/
|
||||
│ ├── Caption.tsx
|
||||
│ ├── Heading.tsx
|
||||
│ ├── Text.tsx
|
||||
│ ├── Typography.types.ts
|
||||
│ └── index.ts
|
||||
├── index.ts (barrel export)
|
||||
└── ATOMS.md (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
## 🚀 Status
|
||||
|
||||
Once atoms are comfortable, the next phase is creating **Molecules** -
|
||||
combinations of atoms:
|
||||
|
||||
- **ProductCard** - Image + Text + Badge + Button
|
||||
- **FormField** - Label + Input + Error Text
|
||||
- **SearchBar** - SearchInput + Button
|
||||
- **RatingInput** - Interactive 5-star rating
|
||||
- **PriceTag** - Price display with formatting
|
||||
- And more...
|
||||
|
||||
See `OPTIMIZATION_PLAN.md` for the full roadmap.
|
||||
Atoms are stable and in use across molecules and organisms. Molecules are
|
||||
implemented — see `components/COMPONENTS.md` for details. See
|
||||
`OPTIMIZATION_PLAN.md` for the full roadmap.
|
||||
|
||||
---
|
||||
|
||||
@@ -540,5 +636,5 @@ See `OPTIMIZATION_PLAN.md` for the full roadmap.
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-03 **Phase:**
|
||||
1 of 5
|
||||
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-18 **Atoms:**
|
||||
14
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BadgeProps } from "./Badge.types";
|
||||
|
||||
export default function Badge({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
primary: "bg-(--color-primary) text-white",
|
||||
secondary: "bg-(--color-border-light) text-(--color-text-secondary)",
|
||||
success: "bg-green-100 text-green-700",
|
||||
danger: "bg-red-100 text-red-700",
|
||||
warning: "bg-yellow-100 text-yellow-700",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-semibold ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
|
||||
size?: "sm" | "md";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface PriceBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
price: number;
|
||||
currency?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PriceBadge({
|
||||
price,
|
||||
currency = "VND",
|
||||
className = "",
|
||||
...props
|
||||
}: PriceBadgeProps) {
|
||||
const formattedPrice =
|
||||
currency === "VND"
|
||||
? price.toLocaleString("vi-VN", {
|
||||
style: "currency",
|
||||
currency: "VND",
|
||||
})
|
||||
: `${price.toFixed(2)} ${currency}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-bold text-(--color-primary) ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{formattedPrice}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as PriceBadge } from "./PriceBadge";
|
||||
export type { BadgeProps } from "./Badge.types";
|
||||
export type { PriceBadgeProps } from "./PriceBadge";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export default function IconButton({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon,
|
||||
disabled = false,
|
||||
className = "",
|
||||
children,
|
||||
style: _style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
secondary:
|
||||
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||
primaryNoBorder:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
bgWhite:
|
||||
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 w-8 text-sm",
|
||||
md: "h-10 w-10 text-base",
|
||||
lg: "h-12 w-12 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ export { Button } from "./buttons";
|
||||
export type { ButtonProps } from "./buttons";
|
||||
|
||||
// Inputs
|
||||
export { TextInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, TextareaProps } from "./inputs";
|
||||
export { TextInput, SearchInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
|
||||
|
||||
// Typography
|
||||
export { Heading, Text, Caption } from "./typography";
|
||||
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
|
||||
|
||||
// Badges
|
||||
export { Badge, PriceBadge } from "./badges";
|
||||
export type { BadgeProps } from "./badges";
|
||||
|
||||
// Dividers
|
||||
export { Divider } from "./dividers";
|
||||
export type { DividerProps } from "./dividers";
|
||||
|
||||
@@ -13,3 +13,17 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
errors?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LoginInputProps } from "./Input.types";
|
||||
|
||||
export default function LoginInput({
|
||||
label,
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
errors,
|
||||
onChange,
|
||||
...restProps
|
||||
}: LoginInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
function isPassword() {
|
||||
if (type === "password") {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{label}
|
||||
</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={name}
|
||||
type={showPassword ? "text" : type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={
|
||||
type === "password"
|
||||
? "Mật khẩu"
|
||||
: "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 ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
{isPassword()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { SearchInputProps } from "./Input.types";
|
||||
|
||||
export default function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
className = "",
|
||||
...props
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<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={value}
|
||||
onChange={onChange}
|
||||
className={`w-full rounded-lg border border-(--color-border) bg-transparent py-2 pr-9 pl-9 text-sm transition-all duration-150 placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{value && onClear && (
|
||||
<button
|
||||
title="Xóa"
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
aria-label="Xóa"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export default function TextInput({
|
||||
/>
|
||||
{icon && (
|
||||
<button
|
||||
title="Thao tác biểu tượng"
|
||||
type="button"
|
||||
onClick={onIconClick}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { default as TextInput } from "./TextInput";
|
||||
export { default as SearchInput } from "./SearchInput";
|
||||
export { default as Textarea } from "./Textarea";
|
||||
export type { TextInputProps, TextareaProps } from "./Input.types";
|
||||
export type {
|
||||
TextInputProps,
|
||||
SearchInputProps,
|
||||
TextareaProps,
|
||||
LoginInputProps,
|
||||
} from "./Input.types";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Product } from "@/lib/types";
|
||||
import type { CartItem } from "@/lib/cart-context";
|
||||
import type { Product, UserRole } from "@/lib/types";
|
||||
|
||||
export interface ProductCardProps {
|
||||
image: string;
|
||||
@@ -18,8 +19,8 @@ export interface ShopCardProps {
|
||||
}
|
||||
|
||||
export interface PaymentSummaryCardProps {
|
||||
cartItems: CartItem[];
|
||||
totalPrice: number;
|
||||
isCustomer: boolean;
|
||||
role: UserRole;
|
||||
backHref: string;
|
||||
onPay?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import {
|
||||
CashPaymentModal,
|
||||
PaymentSuccessModal,
|
||||
QRPaymentModal,
|
||||
} from "@/components/organisms/modals";
|
||||
import type { InvoiceItem } from "@/components/organisms/modals";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaymentSummaryCardProps } from "./Card.types";
|
||||
|
||||
type ModalState = "none" | "cash" | "qr" | "success";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentSummaryCard({
|
||||
cartItems,
|
||||
totalPrice,
|
||||
isCustomer = false,
|
||||
role,
|
||||
backHref,
|
||||
}: PaymentSummaryCardProps) {
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const handlePayment = () => {
|
||||
// UI-only: open review modal after "payment"
|
||||
if (isCustomer) {
|
||||
setIsReviewOpen(true);
|
||||
}
|
||||
const [openModal, setOpenModal] = useState<ModalState>("none");
|
||||
const { clearCart } = useCart();
|
||||
|
||||
const isCustomer = role === "customer";
|
||||
|
||||
const invoiceItems: InvoiceItem[] = cartItems.map((item) => ({
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
}));
|
||||
|
||||
const handlePaymentConfirm = () => setOpenModal("success");
|
||||
const handleSuccessClose = () => {
|
||||
clearCart();
|
||||
setOpenModal("none");
|
||||
};
|
||||
|
||||
return (
|
||||
<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">Bill</h2>
|
||||
<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)">Total</span>
|
||||
<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
|
||||
style="payment"
|
||||
onClick={handlePayment}
|
||||
icon="fa-solid fa-money-bill-wave"
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Cash
|
||||
</Button>
|
||||
{!isCustomer && (
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={() => setOpenModal("cash")}
|
||||
icon="fa-solid fa-money-bill-wave"
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Tiền mặt
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={handlePayment}
|
||||
onClick={() => setOpenModal("qr")}
|
||||
icon="fa-solid fa-qrcode"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
variant={isCustomer ? "primary" : "secondary"}
|
||||
className={isCustomer ? "col-span-2" : ""}
|
||||
>
|
||||
QR Code
|
||||
</Button>
|
||||
|
||||
{isCustomer && (
|
||||
<Link href={backHref || "/"} className="col-span-2">
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={handlePayment}
|
||||
icon="fa-solid fa-star"
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={backHref || "/"}
|
||||
className={isCustomer ? "" : "col-span-2"}
|
||||
>
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={() => setIsReviewOpen(false)}
|
||||
icon="fa-solid fa-arrow-rotate-left"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
Return
|
||||
Quay về
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
<CashPaymentModal
|
||||
isOpen={openModal === "cash"}
|
||||
onClose={() => setOpenModal("none")}
|
||||
onConfirm={handlePaymentConfirm}
|
||||
items={invoiceItems}
|
||||
totalAmount={totalPrice}
|
||||
/>
|
||||
|
||||
<QRPaymentModal
|
||||
isOpen={openModal === "qr"}
|
||||
onClose={() => setOpenModal("none")}
|
||||
onConfirm={handlePaymentConfirm}
|
||||
items={invoiceItems}
|
||||
totalAmount={totalPrice}
|
||||
/>
|
||||
|
||||
<PaymentSuccessModal
|
||||
isOpen={openModal === "success"}
|
||||
onClose={handleSuccessClose}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function ProductCard({
|
||||
icon="fa-cart-plus"
|
||||
aria-label={`Mua ${productName}`}
|
||||
>
|
||||
Buy
|
||||
Mua
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-book-open text-[10px]"></i>
|
||||
View menu
|
||||
Xem menu
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ phone: "", general: "" });
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
|
||||
const validate = (): boolean => {
|
||||
if (!phone.trim()) {
|
||||
setErrors({ phone: "Please enter your phone number", general: "" });
|
||||
return false;
|
||||
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 (!PHONE_REGEX.test(phone)) {
|
||||
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
|
||||
return 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;
|
||||
}
|
||||
return true;
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", general: "" });
|
||||
const success = login(username, password);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
if (success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setErrors({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||
});
|
||||
|
||||
const role = (await res.text().catch(() => "")).trim();
|
||||
|
||||
if (res.ok && (role === "customer" || role === "manager")) {
|
||||
sessionStorage.setItem("login_phone", phone);
|
||||
sessionStorage.setItem("login_role", role);
|
||||
router.push(role === "manager" ? "/login/password" : "/login/otp");
|
||||
} else if (res.status === 404) {
|
||||
setErrors({ phone: "", general: "Phone number not registered" });
|
||||
} else {
|
||||
setErrors({ phone: "", general: "An error occurred, please try again" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "", general: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Message */}
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Phone number
|
||||
</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({ phone: "", general: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<ErrorMessageLogin message={errors.phone} type="secondary" />
|
||||
{/* Username Input */}
|
||||
<LoginInput
|
||||
label="Tên đăng nhập"
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
}}
|
||||
errors={errors.username}
|
||||
/>
|
||||
{errors.username && (
|
||||
<ErrorMessageLogin message={errors.username} type="secondary" />
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<LoginInput
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
errors={errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
Đă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"
|
||||
>
|
||||
Create account
|
||||
Đăng ký tài khoản
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -23,15 +23,15 @@ export default function CategoriesTab() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{categories.length}</strong>{" "}
|
||||
categories
|
||||
<strong className="text-foreground">{categories.length}</strong> danh
|
||||
mục
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setModalCategory("new")}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Add category</span>
|
||||
<span className="hidden sm:inline">Thêm danh mục</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -50,19 +50,19 @@ export default function CategoriesTab() {
|
||||
<p className="text-foreground truncate font-semibold">
|
||||
{cat.name}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">{count} items</p>
|
||||
<p className="text-xs text-(--color-text-muted)">{count} món</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => setModalCategory(cat)}
|
||||
title="Edit"
|
||||
title="Chỉnh sửa"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-pen text-[11px]"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(cat)}
|
||||
title="Delete"
|
||||
title="Xóa"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<i className="fa-solid fa-trash text-[11px]"></i>
|
||||
@@ -75,7 +75,7 @@ export default function CategoriesTab() {
|
||||
{categories.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
|
||||
<p className="text-sm">No categories yet</p>
|
||||
<p className="text-sm">Chưa có danh mục nào</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function CategoryModal({
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||
<h2 className="text-foreground text-lg font-bold">
|
||||
{isEdit ? "Edit category" : "Add new category"}
|
||||
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"}
|
||||
</h2>
|
||||
<button
|
||||
title="Close"
|
||||
@@ -66,7 +66,7 @@ export default function CategoryModal({
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Category name <span className="text-red-500">*</span>
|
||||
Tên danh mục <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
@@ -74,7 +74,7 @@ export default function CategoryModal({
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
placeholder="e.g. Coffee"
|
||||
placeholder="Ví dụ: Cà Phê"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
export default function MenuItemsTab() {
|
||||
const { user } = useAuth();
|
||||
const [eateryId, setEateryId] = useState<string | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPrice, setNewPrice] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadEateryId = useCallback(async (): Promise<string | null> => {
|
||||
if (!user) return null;
|
||||
const data = await gqlFetch<{ allEateries: Eatery[] }>(
|
||||
"{ allEateries { id ownerId } }",
|
||||
);
|
||||
return (
|
||||
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
|
||||
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||
`query($eateryId: String!) {
|
||||
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||
}`,
|
||||
{ eateryId: id },
|
||||
);
|
||||
return data.menuItemsByEatery ?? [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = await loadEateryId();
|
||||
setEateryId(id);
|
||||
if (id) setMenuItems(await loadMenuItems(id));
|
||||
else setMenuItems([]);
|
||||
} catch {
|
||||
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [loadEateryId, loadMenuItems]);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() || !newPrice) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const added = await addManagerMenuItem(newName.trim(), parseFloat(newPrice));
|
||||
if (added) setMenuItems((prev) => [...prev, added]);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
setShowForm(false);
|
||||
} catch {
|
||||
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setSubmitError(null);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
|
||||
Đang tải menu...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eateryId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
|
||||
<p className="text-sm">Quán của bạn chưa được khởi tạo trong hệ thống.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{menuItems.length}</strong> món trong menu
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Thêm món</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-(--color-border-light)">
|
||||
{menuItems.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="py-12 text-center text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
|
||||
Chưa có món nào trong menu
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
menuItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-foreground font-medium">{item.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add Item Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-foreground font-bold">Thêm món mới</h2>
|
||||
<button
|
||||
onClick={closeForm}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên món <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Nhập tên món..."
|
||||
required
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (VNĐ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="Nhập giá..."
|
||||
required
|
||||
min={0}
|
||||
step={500}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p className="text-sm text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1"></i>
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForm}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
"Thêm món"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import type { ProductModalProps } from "./Manager.types";
|
||||
|
||||
@@ -12,6 +13,10 @@ export default function ProductModal({
|
||||
onClose,
|
||||
}: ProductModalProps) {
|
||||
const isEdit = product !== null;
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [priceError, setPriceError] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [form, setForm] = useState<Omit<Product, "id">>({
|
||||
name: product?.name ?? "",
|
||||
category: product?.category ?? categories[0]?.id ?? "",
|
||||
@@ -21,8 +26,28 @@ export default function ProductModal({
|
||||
available: product?.available ?? true,
|
||||
});
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setImageFile(file);
|
||||
if (file) {
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
} else {
|
||||
setImagePreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.price <= 0) {
|
||||
setPriceError("Giá phải lớn hơn 0đ");
|
||||
return;
|
||||
}
|
||||
if (isEdit && product) {
|
||||
onSave({ ...form, id: product.id });
|
||||
} else {
|
||||
@@ -93,15 +118,19 @@ export default function ProductModal({
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
min={1}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
onChange={(e) => {
|
||||
setPriceError("");
|
||||
setForm({ ...form, price: Number(e.target.value) });
|
||||
}}
|
||||
className={`${inputCls} ${priceError ? "border-red-400 focus:border-red-400 focus:ring-red-400/20" : ""}`}
|
||||
placeholder="25000"
|
||||
/>
|
||||
{priceError && (
|
||||
<p className="mt-1 text-xs text-red-500">{priceError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +149,65 @@ export default function ProductModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-sm font-medium text-(--color-text-secondary)"
|
||||
htmlFor="product-image-upload"
|
||||
>
|
||||
Ảnh sản phẩm
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="product-image-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="sr-only"
|
||||
tabIndex={0}
|
||||
/>
|
||||
{imagePreview ? (
|
||||
<div className="relative h-40 overflow-hidden rounded-xl border border-(--color-border)">
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Xem trước ảnh sản phẩm"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
title="Xóa ảnh"
|
||||
className="absolute top-2 right-2 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-none bg-black/50 text-xs text-white transition hover:bg-black/70"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-2 bottom-2 cursor-pointer rounded-lg border-none bg-black/50 px-3 py-1 text-xs text-white transition hover:bg-black/70"
|
||||
>
|
||||
Đổi ảnh
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-(--color-border) bg-transparent px-4 py-6 text-(--color-text-muted) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up text-2xl"></i>
|
||||
<span className="text-sm font-medium">Nhấn để chọn ảnh</span>
|
||||
<span className="text-xs">PNG, JPG, WEBP (tối đa 5MB)</span>
|
||||
</button>
|
||||
)}
|
||||
{imageFile && (
|
||||
<p className="mt-1 truncate text-xs text-(--color-text-muted)">
|
||||
{imageFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">Trạng thái</p>
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function ProductsTab() {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Tìm kiếm món..."
|
||||
aria-label="Tìm kiếm món"
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
{search && (
|
||||
@@ -78,6 +79,7 @@ export default function ProductsTab() {
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||
title="Lọc theo danh mục"
|
||||
aria-label="Lọc theo danh mục"
|
||||
>
|
||||
<option value="all">Tất cả danh mục</option>
|
||||
{categories.map((cat) => (
|
||||
@@ -96,6 +98,7 @@ export default function ProductsTab() {
|
||||
}
|
||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||
title="Lọc theo trạng thái"
|
||||
aria-label="Lọc theo trạng thái"
|
||||
>
|
||||
<option value="all">Tất cả trạng thái</option>
|
||||
<option value="available">Còn hàng</option>
|
||||
@@ -160,7 +163,10 @@ export default function ProductsTab() {
|
||||
<div>
|
||||
<p className="text-foreground font-medium">{p.name}</p>
|
||||
{p.description && (
|
||||
<p className="mt-0.5 max-w-xs truncate text-xs text-(--color-text-muted)">
|
||||
<p
|
||||
title={p.description}
|
||||
className="mt-0.5 max-w-xs truncate text-xs text-(--color-text-muted)"
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -191,6 +197,7 @@ export default function ProductsTab() {
|
||||
<button
|
||||
onClick={() => setModalProduct(p)}
|
||||
title="Chỉnh sửa"
|
||||
aria-label={`Chỉnh sửa: ${p.name}`}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-pen text-xs"></i>
|
||||
@@ -198,6 +205,7 @@ export default function ProductsTab() {
|
||||
<button
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
title="Xóa"
|
||||
aria-label={`Xóa: ${p.name}`}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<i className="fa-solid fa-trash text-xs"></i>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
available
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
||||
available ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{available ? "In stock" : "Out of stock"}
|
||||
{available ? "Còn hàng" : "Tạm hết"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export { default as ComboModal } from "./ComboModal";
|
||||
export { default as ProductsTab } from "./ProductsTab";
|
||||
export { default as CategoriesTab } from "./CategoriesTab";
|
||||
export { default as CombosTab } from "./CombosTab";
|
||||
export { default as MenuItemsTab } from "./MenuItemsTab";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CashPaymentModalProps } from "./Modal.types";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function CashPaymentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
items,
|
||||
totalAmount,
|
||||
}: CashPaymentModalProps) {
|
||||
const [cashReceived, setCashReceived] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setCashReceived("");
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const parsedCash = cashReceived === "" ? 0 : Number(cashReceived);
|
||||
const isInsufficient = cashReceived !== "" && parsedCash < totalAmount;
|
||||
const changeAmount = parsedCash - totalAmount;
|
||||
const canConfirm = cashReceived !== "" && !isInsufficient && parsedCash >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cash-modal-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-lg rounded-2xl border border-(--color-border-light) bg-white p-6 shadow-xl">
|
||||
<h2
|
||||
id="cash-modal-title"
|
||||
className="text-foreground mb-4 text-xl font-bold"
|
||||
>
|
||||
Thanh toán tiền mặt
|
||||
</h2>
|
||||
|
||||
{/* Invoice list */}
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-sm font-semibold tracking-wide text-(--color-text-secondary) uppercase">
|
||||
Hóa đơn
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between border-b border-(--color-border-light) px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="text-foreground font-medium">{item.name}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-(--color-text-muted)">
|
||||
x{item.quantity}
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between bg-(--color-border-light)/30 px-4 py-3">
|
||||
<span className="text-foreground font-bold">Tổng cộng</span>
|
||||
<span className="text-lg font-bold text-(--color-primary)">
|
||||
{formatPrice(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cash input section */}
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="cash-input"
|
||||
className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Số tiền nhận
|
||||
</label>
|
||||
<input
|
||||
id="cash-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step={1000}
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-full rounded-lg border border-(--color-border) bg-transparent px-3 py-2 text-right text-lg font-semibold focus:ring-2 focus:ring-(--color-primary) focus:outline-none"
|
||||
/>
|
||||
{isInsufficient && (
|
||||
<p className="mt-1.5 text-sm text-red-500">
|
||||
<i className="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||
Số tiền không đủ
|
||||
</p>
|
||||
)}
|
||||
{cashReceived !== "" && !isInsufficient && (
|
||||
<div className="mt-2 flex items-center justify-between rounded-lg bg-(--color-border-light)/40 px-3 py-2">
|
||||
<span className="text-sm text-(--color-text-secondary)">
|
||||
Tiền thối
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatPrice(changeAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="fa-solid fa-arrow-left"
|
||||
className="flex-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
Quay lại
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
icon="fa-solid fa-check"
|
||||
className="flex-1"
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Xác nhận
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,3 +12,30 @@ export interface ConfirmModalProps {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface CashPaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
items: InvoiceItem[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface QRPaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
items: InvoiceItem[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface PaymentSuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface PaymentSuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentSuccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: PaymentSuccessModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
router.push("/");
|
||||
onClose();
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, router, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-(--color-border-light) bg-white p-8 shadow-xl">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<i className="fa-solid fa-circle-check text-5xl text-(--color-primary)"></i>
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
Thanh toán thành công!
|
||||
</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Cảm ơn quý khách. Đang chuyển về trang chủ...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { QRPaymentModalProps } from "./Modal.types";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
const COUNTDOWN_SECONDS = 5;
|
||||
|
||||
export default function QRPaymentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
items,
|
||||
totalAmount,
|
||||
}: QRPaymentModalProps) {
|
||||
const [qrValue, setQrValue] = useState("");
|
||||
const [timeLeft, setTimeLeft] = useState(COUNTDOWN_SECONDS);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setQrValue(`payment:${totalAmount}:${Date.now()}`);
|
||||
setTimeLeft(COUNTDOWN_SECONDS);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen, totalAmount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft === 0 && isOpen) {
|
||||
onConfirm();
|
||||
}
|
||||
}, [timeLeft, isOpen, onConfirm]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="qr-modal-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full max-w-lg rounded-2xl border border-(--color-border-light) bg-white p-6 shadow-xl">
|
||||
<h2
|
||||
id="qr-modal-title"
|
||||
className="text-foreground mb-4 text-xl font-bold"
|
||||
>
|
||||
Thanh toán QR Code
|
||||
</h2>
|
||||
|
||||
{/* Invoice list */}
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-sm font-semibold tracking-wide text-(--color-text-secondary) uppercase">
|
||||
Hóa đơn
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between border-b border-(--color-border-light) px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="text-foreground font-medium">{item.name}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-(--color-text-muted)">
|
||||
x{item.quantity}
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between bg-(--color-border-light)/30 px-4 py-3">
|
||||
<span className="text-foreground font-bold">Tổng cộng</span>
|
||||
<span className="text-lg font-bold text-(--color-primary)">
|
||||
{formatPrice(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR code section */}
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-(--color-border-light) px-4 py-5">
|
||||
<p className="text-sm font-medium text-(--color-text-secondary)">
|
||||
Quét mã QR để thanh toán
|
||||
</p>
|
||||
{qrValue && <QRCodeSVG value={qrValue} size={180} />}
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Tự động xác nhận sau{" "}
|
||||
<span className="font-bold text-(--color-primary)">{timeLeft}</span>{" "}
|
||||
giây...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { ReviewModalProps } from "./Modal.types";
|
||||
|
||||
@@ -11,6 +11,12 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
const [review, setReview] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitted) return;
|
||||
const timer = setTimeout(handleClose, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [submitted]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -49,14 +55,11 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
||||
</div>
|
||||
<Heading level={2} id="review-modal-title">
|
||||
Thank you
|
||||
Cảm ơn quý khách
|
||||
</Heading>
|
||||
<Text variant="body2" className="mt-2">
|
||||
We appreciate your feedback!
|
||||
Chúng tôi trân trọng đánh giá của bạn!
|
||||
</Text>
|
||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Review form */
|
||||
@@ -65,21 +68,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
id="review-modal-title"
|
||||
className="text-foreground mb-1 text-xl font-bold"
|
||||
>
|
||||
Your Review
|
||||
Đánh giá của bạn
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-(--color-text-muted)">
|
||||
Tell us about your experience today
|
||||
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
|
||||
</p>
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
||||
Satisfaction level
|
||||
Mức độ hài lòng
|
||||
</p>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Star rating"
|
||||
aria-label="Xếp hạng sao"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isActive = star <= (hovered || rating);
|
||||
@@ -90,7 +93,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHovered(star)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
aria-label={`${star} star`}
|
||||
aria-label={`${star} sao`}
|
||||
aria-pressed={rating === star}
|
||||
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
||||
>
|
||||
@@ -98,7 +101,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
className={
|
||||
isActive
|
||||
? "fa-solid fa-star text-yellow-400"
|
||||
: "fa-regular fa-star text-(--color-border)"
|
||||
: "fa-regular fa-star text-gray-300"
|
||||
}
|
||||
></i>
|
||||
</button>
|
||||
@@ -108,7 +111,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
{rating > 0 && (
|
||||
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
||||
{
|
||||
["", "Very poor", "Poor", "Average", "Good", "Excellent"][
|
||||
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
|
||||
rating
|
||||
]
|
||||
}
|
||||
@@ -120,10 +123,10 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<div className="mb-6">
|
||||
<Textarea
|
||||
id="review-text"
|
||||
label="Comment (optional)"
|
||||
label="Nhận xét (tùy chọn)"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Share your thoughts on the drinks, service..."
|
||||
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
@@ -137,7 +140,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
className="flex-1"
|
||||
icon="fa-arrow-left"
|
||||
>
|
||||
Go back
|
||||
Quay lại
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -146,7 +149,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
className="flex-1"
|
||||
icon="fa-check"
|
||||
>
|
||||
Confirm
|
||||
Xác nhận
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export { default as ReviewModal } from "./ReviewModal";
|
||||
export { default as PaymentSuccessModal } from "./PaymentSuccessModal";
|
||||
export { default as CashPaymentModal } from "./CashPaymentModal";
|
||||
export { default as QRPaymentModal } from "./QRPaymentModal";
|
||||
export type { ReviewModalProps, ConfirmModalProps } from "./Modal.types";
|
||||
export type { CashPaymentModalProps, InvoiceItem } from "./Modal.types";
|
||||
export type { QRPaymentModalProps } from "./Modal.types";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -26,18 +26,18 @@ function isToday(d: Date): boolean {
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
];
|
||||
|
||||
export default function MobileShiftView({
|
||||
@@ -94,7 +94,7 @@ export default function MobileShiftView({
|
||||
{/* Month navigation */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="Previous month"
|
||||
title="Trở lại tháng trước"
|
||||
type="button"
|
||||
onClick={goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -105,7 +105,7 @@ export default function MobileShiftView({
|
||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Next month"
|
||||
title="Trở lại tháng sau"
|
||||
type="button"
|
||||
onClick={goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -173,22 +173,22 @@ export default function MobileShiftView({
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Available
|
||||
Còn trống
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Registered</span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
On leave
|
||||
Nghỉ phép
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Absent</span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Vắng</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@ export default function MobileShiftView({
|
||||
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||
({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
|
||||
({selectedShifts.length} ca)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function MobileShiftView({
|
||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
No shifts for this day
|
||||
Không có ca làm trong ngày này
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMemo } from "react";
|
||||
|
||||
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -125,7 +125,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||
<span className="text-[10px] text-blue-600">
|
||||
{summary.available} available
|
||||
{summary.available} trống
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -133,7 +133,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
|
||||
<span className="text-[10px] text-blue-800">
|
||||
{summary.registered} registered
|
||||
{summary.registered} đã ĐK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -141,7 +141,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||
<span className="text-[10px] text-purple-600">
|
||||
{summary.leave} on leave
|
||||
{summary.leave} nghỉ
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +149,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-[10px] text-red-600">
|
||||
{summary.absent} absent
|
||||
{summary.absent} vắng
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
// Validate
|
||||
if (!date || !startTime || !endTime) {
|
||||
setError("Please fill in all required fields.");
|
||||
setError("Vui lòng điền đầy đủ thông tin.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ShiftCreateModal({
|
||||
const endMinutes = eh * 60 + em;
|
||||
|
||||
if (endMinutes <= startMinutes) {
|
||||
setError("End time must be after start time.");
|
||||
setError("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,10 +77,10 @@ export default function ShiftCreateModal({
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-foreground text-base font-bold">
|
||||
Create New Shift
|
||||
Tạo ca làm mới
|
||||
</h2>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Add a shift time slot for staff
|
||||
Thêm khung giờ ca làm cho nhân viên
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -98,7 +98,7 @@ export default function ShiftCreateModal({
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Date
|
||||
Ngày
|
||||
</label>
|
||||
<input
|
||||
title="Date"
|
||||
@@ -113,7 +113,7 @@ export default function ShiftCreateModal({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Start Time
|
||||
Giờ bắt đầu
|
||||
</label>
|
||||
<input
|
||||
title="Start Time"
|
||||
@@ -125,7 +125,7 @@ export default function ShiftCreateModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
End Time
|
||||
Giờ kết thúc
|
||||
</label>
|
||||
<input
|
||||
title="End Time"
|
||||
|
||||
@@ -22,7 +22,7 @@ const MONTH_NAMES_EN = [
|
||||
"December",
|
||||
];
|
||||
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateShort(d: Date): string {
|
||||
@@ -94,7 +94,7 @@ export default function WeeklySchedule({
|
||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="Previous week"
|
||||
title="Tuần trước"
|
||||
type="button"
|
||||
onClick={goToPrevWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -105,7 +105,7 @@ export default function WeeklySchedule({
|
||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Next week"
|
||||
title="Tuần sau"
|
||||
type="button"
|
||||
onClick={goToNextWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -192,7 +192,7 @@ export default function WeeklySchedule({
|
||||
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Add shift
|
||||
Thêm ca
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,7 +213,7 @@ export default function WeeklySchedule({
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||
Department
|
||||
Bộ phận
|
||||
</th>
|
||||
{weekDates.map((date, i) => (
|
||||
<th
|
||||
@@ -273,7 +273,7 @@ export default function WeeklySchedule({
|
||||
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Add shift
|
||||
Thêm ca
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ShopGrid({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||
<p className="text-base font-medium">No shops found matching your search</p>
|
||||
<p className="text-base font-medium">Không tìm thấy quán nào phù hợp</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
|
||||
/**
|
||||
* Auth layout template — centers content in the screen.
|
||||
* Used by login and register pages.
|
||||
*/
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
export type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
@@ -2,6 +2,10 @@
|
||||
export { MainLayout } from "./main-layout";
|
||||
export type { MainLayoutProps } from "./main-layout";
|
||||
|
||||
// Auth Layout
|
||||
export { AuthLayout } from "./auth-layout";
|
||||
export type { AuthLayoutProps } from "./auth-layout";
|
||||
|
||||
// Feed Layout
|
||||
export { FeedLayout } from "./feed-layout";
|
||||
export type { FeedLayoutProps } from "./feed-layout";
|
||||
|
||||
@@ -13,16 +13,16 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
|
||||
* Redirects non-managers away; shows loading state while auth resolves.
|
||||
*/
|
||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
const { user, isInitialized } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && user !== null && user.role !== "manager") {
|
||||
if (user !== null && user.role !== "manager") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [user, isInitialized, router]);
|
||||
}, [user, router]);
|
||||
|
||||
if (!isInitialized || user === null) {
|
||||
if (user === null) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend-container
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.3
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
|
||||
+224
-276
@@ -2,6 +2,8 @@
|
||||
|
||||
> Tài liệu chi tiết về layout components (Header, Footer) và responsive behavior
|
||||
|
||||
> **Last Updated:** 2026-04-18
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -21,180 +23,197 @@ layouts/
|
||||
|
||||
## Header (layouts/header.tsx)
|
||||
|
||||
**File:** layouts/header.tsx **Type:** Client component **Description:** Sticky
|
||||
top navigation bar with brand info and authentication status display.
|
||||
**File:** layouts/header.tsx **Type:** Client component (`"use client"`)
|
||||
**Description:** Sticky top navigation bar with brand info and authentication
|
||||
status display. Uses `useAuth()` context for real auth state and `useRouter()`
|
||||
for navigation.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Logo] [Shop Name] [Tagline] │ [Auth Status] │
|
||||
│ [Logo] [Shop Name] [Tagline] │ [Auth Button(s)] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
height: 72px (var(--spacing-header-height))
|
||||
height: var(--spacing-header-height) (72px)
|
||||
```
|
||||
|
||||
**2-Column Layout:**
|
||||
|
||||
- **Left:** Shop branding (logo + name + tagline)
|
||||
- **Right:** Auth status indicator
|
||||
- **Left:** Shop branding (logo + name + tagline) — wrapped in `<Link href="/">`
|
||||
- **Right:** Auth status indicator (varies by user role)
|
||||
|
||||
### Features
|
||||
|
||||
#### Logo & Branding
|
||||
|
||||
- Shop logo (40x40px)
|
||||
- Shop name (bold text)
|
||||
- Shop logo rendered with `next/image` (fill, 40x40px / 44x44px on md+)
|
||||
- Shop name (bold, color transitions on hover)
|
||||
- Tagline (hidden on mobile < md, visible md+)
|
||||
- Entire left section is a link to `/`
|
||||
|
||||
**Responsive:** | Breakpoint | Logo | Name | Tagline |
|
||||
|-----------|------|------|---------| | < sm (640px) | Yes | Yes | Hidden | |
|
||||
sm-md | Yes | Yes | Hidden | | md+ (768px) | Yes | Yes | Visible |
|
||||
**Responsive:**
|
||||
|
||||
| Breakpoint | Logo | Name | Tagline |
|
||||
| ------------ | --------- | ---- | ------- |
|
||||
| < sm (640px) | h-10/w-10 | Yes | Hidden |
|
||||
| sm-md | h-10/w-10 | Yes | Hidden |
|
||||
| md+ (768px) | h-11/w-11 | Yes | Visible |
|
||||
|
||||
#### Authentication Status
|
||||
|
||||
The header cycles through 3 auth states (for UI demo):
|
||||
The header reads from `useAuth()` and reflects the actual logged-in user role.
|
||||
|
||||
**State 1: Guest (Not logged in)**
|
||||
**State 1: Guest (user = null)**
|
||||
|
||||
```
|
||||
[brown button] "Dang nhap"
|
||||
[brown button] "Đăng nhập" ← icon always shown, label hidden on xs
|
||||
```
|
||||
|
||||
- Guest state (user = null)
|
||||
- Brown primary button
|
||||
- Click: toggles to next state
|
||||
- `title="Đăng nhập"` on button
|
||||
- Clicking navigates to `/login`
|
||||
- Icon: `fa-solid fa-right-to-bracket`
|
||||
|
||||
**State 2: Manager**
|
||||
**State 2: Manager (user.role === "manager")**
|
||||
|
||||
```
|
||||
[caramel/gold badge] "👔 Quản lý"
|
||||
[Dashboard link] [Logout icon button]
|
||||
```
|
||||
|
||||
- Shows manager role
|
||||
- Gold/caramel colored badge
|
||||
- User tie icon
|
||||
- Name: Nguyễn Văn An
|
||||
- Dashboard link → `/manager`
|
||||
- Icon: `fa-solid fa-user-tie`
|
||||
- Label "Dashboard" hidden on xs, visible sm+
|
||||
- Logout button: `title="Đăng xuất"`, `aria-label="Đăng xuất"`
|
||||
- Logout icon: `fa-solid fa-right-from-bracket`
|
||||
- Hover: red tint on logout button
|
||||
|
||||
**State 3: Staff**
|
||||
**State 3: Staff (user.role === "staff")**
|
||||
|
||||
```
|
||||
[border button] "👤 [Name]"
|
||||
[Ca làm link] [Avatar + Name button (logout on click)]
|
||||
```
|
||||
|
||||
- Shows staff role with avatar placeholder
|
||||
- Bordered button style
|
||||
- Name: Trần Thị Bình
|
||||
- Click: toggles back to Guest state
|
||||
- Ca làm link → `/staff/schedule`
|
||||
- Icon: `fa-solid fa-calendar-check`
|
||||
- Label "Ca làm" hidden on xs, visible sm+
|
||||
- Staff button: `title="Nhấn để đăng xuất"`, `aria-label="Đăng xuất"`
|
||||
- Avatar: colored circle with `fa-solid fa-user`
|
||||
- Name hidden on xs, visible sm+
|
||||
|
||||
**State 4: Customer (user.role === "customer")**
|
||||
|
||||
```
|
||||
[Customer icon + "Khách hàng" button (logout on click)]
|
||||
```
|
||||
|
||||
- `title="Khách hàng - {user.phone} - Nhấn để đăng xuất"`
|
||||
- Avatar circle with `fa-solid fa-user`
|
||||
- Label "Khách hàng" hidden on xs, visible sm+
|
||||
|
||||
### Responsive Behavior
|
||||
|
||||
| Breakpoint | Layout |
|
||||
| ------------- | ------------------------------- |
|
||||
| < sm | Stacked vertically |
|
||||
| sm-md (640px) | Side-by-side, reduced spacing |
|
||||
| md+ (768px) | Full width, comfortable spacing |
|
||||
| Breakpoint | Layout |
|
||||
| ------------- | -------------------------------------- |
|
||||
| < sm (640px) | Button labels hidden, icons only |
|
||||
| sm-md (640px) | Button labels visible, reduced spacing |
|
||||
| md+ (768px) | Full layout including tagline |
|
||||
|
||||
**Mobile (<640px):**
|
||||
|
||||
- Logo smaller
|
||||
- Name only (no tagline)
|
||||
- Auth button text hidden, icon shown
|
||||
- Logo 40x40px
|
||||
- Shop name only (no tagline)
|
||||
- Auth button shows icon only (label hidden via `hidden sm:inline`)
|
||||
- Touch-friendly padding
|
||||
|
||||
**Desktop (≥640px):**
|
||||
**Desktop (≥768px):**
|
||||
|
||||
- Normal logo size
|
||||
- Logo 44x44px
|
||||
- Full layout with tagline
|
||||
- Button with text and icon
|
||||
- Buttons show icon + text label
|
||||
- Comfortable spacing
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Logo image: `alt="Logo {SHOP_INFO.name}"`
|
||||
- Guest button: `title="Đăng nhập"`
|
||||
- Manager logout button: `title="Đăng xuất"`, `aria-label="Đăng xuất"`
|
||||
- Staff logout button: `title="Nhấn để đăng xuất"`, `aria-label="Đăng xuất"`
|
||||
- Customer button: `title="Khách hàng - {phone} - Nhấn để đăng xuất"`
|
||||
- Icons are presentational (no aria-hidden needed — buttons have
|
||||
title/aria-label)
|
||||
|
||||
### Styling
|
||||
|
||||
| Element | Classes |
|
||||
| -------------------- | --------------------------------------------------------- |
|
||||
| Header wrapper | `sticky top-0 z-50 bg-white shadow-md` |
|
||||
| Container | `max-w-7xl mx-auto px-4 md:px-6 h-72px flex items-center` |
|
||||
| Left section (brand) | `flex items-center gap-3 flex-1` |
|
||||
| Right section (auth) | `flex items-center gap-3 shrink-0` |
|
||||
| Logo image | `w-10 h-10 md:w-12 md:h-12` |
|
||||
| Brand text | `font-bold text-sm md:text-base` |
|
||||
| Tagline | `hidden md:block text-xs text-muted-foreground` |
|
||||
| Auth button | `px-4 py-2 rounded-lg text-sm font-medium` |
|
||||
| Element | Classes |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Header wrapper | `sticky top-0 z-50 h-(--spacing-header-height) w-full border-b bg-(--color-bg-header) shadow-[...]` |
|
||||
| Container | `mx-auto flex h-full max-w-screen-2xl items-center justify-between gap-6 px-6 md:px-8 lg:px-12` |
|
||||
| Left section (brand) | `group flex shrink-0 items-center gap-4 no-underline` (Link) |
|
||||
| Logo wrapper | `relative h-10 w-10 shrink-0 md:h-11 md:w-11` |
|
||||
| Brand text | `text-base font-bold text-(--color-primary-dark) md:text-lg` |
|
||||
| Tagline | `hidden text-xs text-(--color-text-muted) md:block` |
|
||||
| Right section (auth) | `flex shrink-0 items-center gap-3` |
|
||||
| Guest button | `flex items-center gap-2.5 rounded-xl bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white` |
|
||||
| Manager link | `flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light)` |
|
||||
| Logout button | `flex items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 hover:border-red-300 hover:bg-red-50 hover:text-red-500` |
|
||||
| Staff button | `flex items-center gap-2.5 rounded-xl border border-(--color-border) bg-background` |
|
||||
| Customer button | `flex items-center gap-2.5 rounded-xl bg-(--color-primary-light) px-4 py-2 text-white` |
|
||||
|
||||
### CSS Variables Used
|
||||
|
||||
```css
|
||||
--spacing-header-height: 72px;
|
||||
--color-primary: brown;
|
||||
--color-primary-dark: darker brown;
|
||||
--color-text-primary: text color;
|
||||
--color-text-muted: gray text;
|
||||
--color-shadow-sm: subtle shadow;
|
||||
--spacing-header-height /* 72px — header height */
|
||||
--color-bg-header /* header background */
|
||||
--color-border /* border color */
|
||||
--color-shadow-sm /* subtle shadow */
|
||||
--color-primary /* brand primary (brown) */
|
||||
--color-primary-dark /* darker brown */
|
||||
--color-primary-light /* lighter brown */
|
||||
--color-accent /* accent/gold color */
|
||||
--color-accent-light /* light accent background */
|
||||
--color-text-muted /* gray/muted text */
|
||||
--color-text-secondary /* secondary text color */
|
||||
--color-border-light /* light border */
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **next/image** - Logo image rendering
|
||||
- **next/link** - Navigation links
|
||||
- **lib/constants:** SHOP_INFO, MOCK_USERS
|
||||
- **lib/types:** User
|
||||
- **FontAwesome icons:** fa-sign-in-alt, fa-sign-out-alt, fa-user-tie,
|
||||
fa-user-circle
|
||||
|
||||
### Key Code Snippets
|
||||
|
||||
**Auth Demo State Machine:**
|
||||
|
||||
```typescript
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
const handleAuthClick = () => {
|
||||
if (!user) {
|
||||
setUser(MOCK_USERS.manager);
|
||||
} else if (user.role === "manager") {
|
||||
setUser(MOCK_USERS.staff);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Responsive Brand Text:**
|
||||
|
||||
```typescript
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm md:text-base">{SHOP_INFO.name}</span>
|
||||
<span className="hidden md:block text-xs text-muted-foreground">
|
||||
{SHOP_INFO.tagline}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
- **next/image** — Logo image rendering
|
||||
- **next/link** — Brand link, dashboard link, schedule link
|
||||
- **next/navigation** — `useRouter` for programmatic navigation
|
||||
- **lib/auth-context:** `useAuth` — user state, logout
|
||||
- **lib/constants:** `SHOP_INFO`
|
||||
|
||||
---
|
||||
|
||||
## Footer (layouts/footer.tsx)
|
||||
|
||||
**File:** layouts/footer.tsx **Type:** Client component (uses constants)
|
||||
**File:** layouts/footer.tsx **Type:** Server component (no `"use client"`)
|
||||
**Description:** Site footer with shop information, social links, and WiFi
|
||||
details.
|
||||
details. Copyright year is dynamic via `new Date().getFullYear()`.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ [Brand Info] │ [Social + WiFi] │
|
||||
├────────────────────────────────────────────┤
|
||||
│ © 2024 Coffee Shop. Made with ❤️ in VN │
|
||||
└────────────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ [Brand Info] │ [Social + WiFi] │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ © {year} Coffee Shop. All rights reserved. | Powered by ... │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**12-Column Grid Layout:**
|
||||
|
||||
| Breakpoint | Brand Info | Social + WiFi |
|
||||
| ------------ | ----------- | ------------- |
|
||||
| Mobile | col-span-12 | col-span-12 |
|
||||
| md (768px) | col-span-6 | col-span-6 |
|
||||
| lg+ (1024px) | col-span-6 | col-span-6 |
|
||||
| Breakpoint | Brand Info | Right column (Social + WiFi) |
|
||||
| ------------ | ----------- | -------------------------------- |
|
||||
| Mobile | col-span-12 | col-span-12 |
|
||||
| md (768px) | col-span-6 | col-span-6 |
|
||||
| lg+ (1024px) | col-span-8 | col-span-4 |
|
||||
| xl+ | col-span-6 | col-span-6 (with 2-col sub-grid) |
|
||||
|
||||
The right column (Social + WiFi) uses an internal sub-grid:
|
||||
|
||||
- Mobile/lg: stacked (1 column)
|
||||
- xl+: side-by-side (2 columns)
|
||||
|
||||
### Sections
|
||||
|
||||
@@ -202,179 +221,114 @@ details.
|
||||
|
||||
**Content:**
|
||||
|
||||
- Shop logo (40x40px)
|
||||
- Shop name (bold)
|
||||
- Tagline (gray text)
|
||||
- Address with location icon
|
||||
- Phone with phone icon
|
||||
- Email with envelope icon
|
||||
- Open hours with clock icon
|
||||
- Shop logo (40x40px, `next/image`)
|
||||
- Shop name (bold, accent color)
|
||||
- Tagline (gray, 75% opacity)
|
||||
- Address with `fa-solid fa-location-dot`
|
||||
- Phone (clickable `tel:` link) with `fa-solid fa-phone`
|
||||
- Email (clickable `mailto:` link) with `fa-solid fa-envelope`
|
||||
- Open hours with `fa-solid fa-clock`
|
||||
|
||||
**Responsive:**
|
||||
#### 2. Social Links
|
||||
|
||||
```
|
||||
Mobile:
|
||||
└─ Logo
|
||||
└─ Name & Tagline (stacked)
|
||||
└─ Address
|
||||
└─ Phone
|
||||
└─ Email
|
||||
└─ Open Hours
|
||||
**Links (heading: "Kết nối"):**
|
||||
|
||||
Desktop:
|
||||
└─ Logo + Name/Tagline (flexbox, side-by-side)
|
||||
└─ Address, Phone, Email, Hours (stacked below)
|
||||
```
|
||||
|
||||
#### 2. Social Links (Top Right)
|
||||
|
||||
**Links:**
|
||||
|
||||
- Facebook icon → SHOP_INFO.facebook
|
||||
- TikTok icon → SHOP_INFO.tiktok
|
||||
- Website icon → SHOP_INFO.website
|
||||
- Facebook → `SOCIAL_LINKS.facebook` (branded blue icon, opens `_blank`)
|
||||
- TikTok → `SOCIAL_LINKS.tiktok` (black icon, opens `_blank`)
|
||||
- Website → `SOCIAL_LINKS.website` (primary color icon, uses `next/link`)
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Icons in circle backgrounds
|
||||
- Hover effect (color change)
|
||||
- Flex row layout
|
||||
- Centered alignment
|
||||
- Each item: icon badge (8x8 rounded-lg) + label text
|
||||
- Hover: accent color + full opacity
|
||||
|
||||
#### 3. WiFi Card (Bottom Right)
|
||||
#### 3. WiFi Card
|
||||
|
||||
**Content:**
|
||||
**Content (heading: "WiFi Miễn Phí"):**
|
||||
|
||||
- "📶 WiFi miễn phí" label
|
||||
- Network name (monospace)
|
||||
- Password (monospace, can be hidden/shown)
|
||||
- WiFi icon (`fa-solid fa-wifi`)
|
||||
- Network name (monospace, accent-bordered)
|
||||
- Password (monospace, accent-bordered, always visible — no toggle)
|
||||
|
||||
**Note:** Current implementation shows password in plain text (no hide/show
|
||||
toggle). This differs from older documentation.
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Light gray background
|
||||
- Rounded borders
|
||||
- Semi-transparent dark panel (`bg-(--color-primary-dark) bg-opacity-30`)
|
||||
- Rounded xl border
|
||||
- Stacked label + value rows (no overflow risk)
|
||||
- Monospace font for credentials
|
||||
- Eye icon toggle for password visibility
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
📶 WiFi miễn phí
|
||||
┌────────────────────────┐
|
||||
│ Network: CoffeeShop │
|
||||
│ Password: •••••••• │
|
||||
│ (eye icon) │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
#### 4. Copyright Bar (Bottom)
|
||||
#### 4. Bottom Bar
|
||||
|
||||
**Content:**
|
||||
|
||||
- Copyright text: "© 2024 Coffee Shop"
|
||||
- "Made with ❤️ in Vietnam"
|
||||
- Centered
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Small text
|
||||
- Gray color
|
||||
- Subtle separator line above
|
||||
- Left: `© {year} {SHOP_INFO.name}. All rights reserved.`
|
||||
- Right: `Được vận hành ❤ bằng Drinkool`
|
||||
- Responsive: stacked on mobile, side-by-side on sm+
|
||||
|
||||
### Responsive Behavior
|
||||
|
||||
**Mobile (<768px):**
|
||||
|
||||
- Single column layout
|
||||
- Stacked sections
|
||||
- Stacked sections (brand, then social+WiFi)
|
||||
- Full width
|
||||
- Padding around edges
|
||||
|
||||
**Tablet (768px):**
|
||||
|
||||
- 2-column layout
|
||||
- Brand info left, Social + WiFi right
|
||||
- Equal width columns
|
||||
- 2-column: brand (6 cols) | social+WiFi (6 cols)
|
||||
- Social and WiFi stacked within right column
|
||||
|
||||
**Desktop (≥768px):**
|
||||
**Desktop (≥1024px):**
|
||||
|
||||
- 2-column layout with more spacing
|
||||
- Brand info slightly smaller
|
||||
- Comfortable padding
|
||||
- Brand 8 cols, right 4 cols
|
||||
- Social and WiFi stacked in right col
|
||||
|
||||
**XL (≥1280px):**
|
||||
|
||||
- Brand 6 cols, right 6 cols
|
||||
- Social and WiFi side-by-side in right col sub-grid
|
||||
|
||||
### Styling
|
||||
|
||||
| Element | Classes |
|
||||
| -------------- | --------------------------------------------------------- |
|
||||
| Footer wrapper | `bg-gray-100 border-t` |
|
||||
| Container | `max-w-7xl mx-auto px-4 py-8` |
|
||||
| Grid | `grid grid-cols-12 gap-8` |
|
||||
| Brand section | `col-span-12 md:col-span-6` |
|
||||
| Social section | `col-span-12 md:col-span-6` |
|
||||
| Logo | `w-10 h-10` |
|
||||
| Info text | `text-sm text-gray-600` |
|
||||
| Social icons | `w-10 h-10 rounded-full flex items-center justify-center` |
|
||||
| WiFi card | `bg-white p-4 rounded-lg border` |
|
||||
| Copyright bar | `border-t mt-8 pt-6 text-center text-xs text-gray-500` |
|
||||
| Element | Classes |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| Footer wrapper | `w-full overflow-x-hidden bg-(--color-bg-footer) text-(--color-text-on-dark)` |
|
||||
| Container | `mx-auto max-w-screen-2xl px-4 py-10 md:px-6 lg:px-8` |
|
||||
| Grid | `grid grid-cols-12 gap-x-8 gap-y-8` |
|
||||
| Brand section | `col-span-12 md:col-span-6 lg:col-span-8 xl:col-span-6` |
|
||||
| Right section | `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` |
|
||||
| Logo | `relative h-10 w-10 shrink-0` |
|
||||
| Shop name | `text-lg font-bold text-(--color-accent)` |
|
||||
| Tagline | `text-sm leading-relaxed opacity-75` |
|
||||
| Contact list | `flex flex-col gap-2 text-sm opacity-80` |
|
||||
| Social icon badge | `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg` |
|
||||
| WiFi card | `rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4` |
|
||||
| WiFi value | `rounded border border-(--color-accent) px-2 py-1 font-mono font-bold text-(--color-accent)` |
|
||||
| Bottom bar | `border-t border-white border-opacity-10` |
|
||||
| Copyright row | `mx-auto flex max-w-screen-2xl flex-col sm:flex-row items-center justify-between gap-2 px-4 py-4 text-xs opacity-50` |
|
||||
|
||||
### CSS Variables Used
|
||||
|
||||
```css
|
||||
--color-primary: brand color;
|
||||
--color-text-secondary: secondary text color;
|
||||
--color-text-muted: muted/gray text color;
|
||||
--color-accent: accent color;
|
||||
--color-border: border color;
|
||||
--color-bg-*: background colors;
|
||||
--color-shadow-sm: subtle shadow;
|
||||
--color-bg-footer /* dark footer background */
|
||||
--color-text-on-dark /* light text for dark backgrounds */
|
||||
--color-accent /* accent/gold color */
|
||||
--color-primary /* brand primary (brown) */
|
||||
--color-primary-dark /* darker brown */
|
||||
--color-primary-light /* lighter brown */
|
||||
--color-border /* border color */
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **next/image** - Shop logo
|
||||
- **next/link** - Social links
|
||||
- **lib/constants:** SHOP_INFO, SOCIAL_LINKS
|
||||
- **FontAwesome icons:** location-dot, phone, envelope, clock, facebook, tiktok,
|
||||
globe, wifi, eye, eye-slash
|
||||
|
||||
### Key Code Snippets
|
||||
|
||||
**Brand Info Section:**
|
||||
|
||||
```typescript
|
||||
<div className="flex gap-4">
|
||||
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} width={40} height={40} />
|
||||
<div>
|
||||
<h3 className="font-bold">{SHOP_INFO.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{SHOP_INFO.tagline}</p>
|
||||
<p className="text-sm mt-2">{SHOP_INFO.address}</p>
|
||||
{/* etc */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Social Links:**
|
||||
|
||||
```typescript
|
||||
<div className="flex gap-3">
|
||||
<Link href={SOCIAL_LINKS.facebook} target="_blank" rel="noopener">
|
||||
<i className="fab fa-facebook" />
|
||||
</Link>
|
||||
{/* etc */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**WiFi Display:**
|
||||
|
||||
```typescript
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<p className="font-semibold text-sm">📶 WiFi miễn phí</p>
|
||||
<p className="font-mono text-xs mt-2">{SHOP_INFO.wifi.name}</p>
|
||||
<p className="font-mono text-xs">
|
||||
{showPassword ? SHOP_INFO.wifi.password : "••••••••"}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
- **next/image** — Shop logo
|
||||
- **next/link** — Website social link
|
||||
- **lib/constants:** `SHOP_INFO`, `SOCIAL_LINKS`
|
||||
- **FontAwesome icons:** `location-dot`, `phone`, `envelope`, `clock`,
|
||||
`fa-brands fa-facebook-f`, `fa-brands fa-tiktok`, `globe`, `wifi`, `heart`
|
||||
|
||||
---
|
||||
|
||||
@@ -398,7 +352,7 @@ export default function RootLayout({ children }) {
|
||||
|
||||
**Result:**
|
||||
|
||||
- Header: Always at top (sticky)
|
||||
- Header: Always at top (sticky, z-50)
|
||||
- Content: Takes full width between header/footer
|
||||
- Footer: Always at bottom
|
||||
|
||||
@@ -409,30 +363,22 @@ export default function RootLayout({ children }) {
|
||||
### Header Responsive Pattern
|
||||
|
||||
```css
|
||||
/* Mobile first */
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
/* Mobile first — icon-only auth buttons */
|
||||
.header-auth-label {
|
||||
display: none; /* hidden on xs */
|
||||
}
|
||||
|
||||
/* Tablet+ */
|
||||
/* sm+ — show button labels */
|
||||
@media (min-width: 640px) {
|
||||
.header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
.header-auth-label {
|
||||
display: inline; /* hidden sm:inline */
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop+ */
|
||||
/* md+ — show tagline */
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
display: block; /* Show tagline on desktop */
|
||||
display: block; /* hidden md:block */
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -440,17 +386,14 @@ export default function RootLayout({ children }) {
|
||||
### Footer Responsive Pattern
|
||||
|
||||
```css
|
||||
/* Mobile: stacked */
|
||||
/* Mobile: single column */
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
/* Tablet: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
/* md: brand 6 cols, right 6 cols */
|
||||
/* lg: brand 8 cols, right 4 cols */
|
||||
/* xl: brand 6 cols, right 6 cols — right col uses xl:grid-cols-2 */
|
||||
```
|
||||
|
||||
---
|
||||
@@ -483,33 +426,38 @@ CSS variables are set up for dark mode support. To enable:
|
||||
|
||||
### Header
|
||||
|
||||
- Logo has alt text
|
||||
- Auth button has aria-label
|
||||
- Icons have semantic meaning
|
||||
- Good contrast ratios
|
||||
- Logo image has descriptive `alt` text: `"Logo {SHOP_INFO.name}"`
|
||||
- Guest button: `title="Đăng nhập"`
|
||||
- Manager logout button: `title="Đăng xuất"` + `aria-label="Đăng xuất"`
|
||||
- Staff logout button: `title="Nhấn để đăng xuất"` + `aria-label="Đăng xuất"`
|
||||
- Customer button: `title="Khách hàng - {phone} - Nhấn để đăng xuất"`
|
||||
- Interactive elements have hover and active states (visual feedback)
|
||||
- Touch targets are sized appropriately (px-4/5 + py-2/2.5)
|
||||
|
||||
### Footer
|
||||
|
||||
- Headings use semantic tags
|
||||
- Links have descriptive text
|
||||
- Icons are decorative (aria-hidden)
|
||||
- Monospace font for technical info (WiFi credentials)
|
||||
- Phone and email are clickable `<a>` links (`tel:`, `mailto:`)
|
||||
- Social links open in `_blank` with `rel="noopener noreferrer"`
|
||||
- Section headings use `<h3>` semantic tags
|
||||
- Icons are decorative within labelled list items
|
||||
- WiFi credentials in monospace font for readability
|
||||
|
||||
### General
|
||||
|
||||
- Touch targets ≥ 48px on mobile
|
||||
- Sufficient color contrast
|
||||
- Semantic HTML structure
|
||||
- ARIA labels where needed
|
||||
- Touch targets ≥ 44px (py-2.5 + px-4/5 buttons)
|
||||
- `active:scale-95` on interactive buttons for tactile feedback
|
||||
- Semantic HTML structure (`<header>`, `<footer>`, `<nav>` implicit via links)
|
||||
- ARIA labels on icon-only buttons (logout buttons)
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep layouts simple:** Avoid complex nested layouts
|
||||
2. **Responsive first:** Use mobile-first CSS approach
|
||||
2. **Responsive first:** Use mobile-first CSS approach (`hidden sm:inline`)
|
||||
3. **Reuse components:** Use Header/Footer across all pages
|
||||
4. **Props over hardcoding:** Use lib/constants for data
|
||||
5. **Type safety:** Use TypeScript for component props
|
||||
6. **Performance:** Optimize images with next/image
|
||||
7. **Accessibility:** Add ARIA labels and semantic HTML
|
||||
6. **Performance:** Optimize images with next/image (`fill`, `sizes`,
|
||||
`priority`)
|
||||
7. **Accessibility:** Add title/aria-label on icon-only buttons
|
||||
|
||||
+9
-9
@@ -48,7 +48,7 @@ export default function Footer() {
|
||||
<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>Address: {SHOP_INFO.address}</span>
|
||||
<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>
|
||||
@@ -56,7 +56,7 @@ export default function Footer() {
|
||||
href={`tel:${SHOP_INFO.phone}`}
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
Phone: {SHOP_INFO.phone}
|
||||
Số điện thoại: {SHOP_INFO.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
@@ -83,7 +83,7 @@ export default function Footer() {
|
||||
{/* ── 2. Social links ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
Follow Us
|
||||
Kết nối
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li>
|
||||
@@ -129,20 +129,20 @@ export default function Footer() {
|
||||
{/* ── 3. WiFi card ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
Free WiFi
|
||||
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">
|
||||
Connect for free
|
||||
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">
|
||||
Network name
|
||||
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}
|
||||
@@ -150,7 +150,7 @@ export default function Footer() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Password
|
||||
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}
|
||||
@@ -170,9 +170,9 @@ export default function Footer() {
|
||||
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
Powered by{" "}
|
||||
Được vận hành{" "}
|
||||
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
||||
Drinkool
|
||||
bằng Drinkool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-9
@@ -72,11 +72,11 @@ export default function Header() {
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Sign in"
|
||||
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">Sign in</span>
|
||||
<span className="hidden sm:inline">Đăng nhập</span>
|
||||
</button>
|
||||
) : user.role === "manager" ? (
|
||||
/* Manager: dashboard link + logout */
|
||||
@@ -90,8 +90,8 @@ export default function Header() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
title="Đăng xuất"
|
||||
aria-label="Đăng xuất"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
||||
@@ -105,12 +105,12 @@ export default function Header() {
|
||||
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-check text-base"></i>
|
||||
<span className="hidden sm:inline">My Shifts</span>
|
||||
<span className="hidden sm:inline">Ca làm</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Click to sign out"
|
||||
aria-label="Sign out"
|
||||
title="Nhấn để đăng xuất"
|
||||
aria-label="Đă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 */}
|
||||
@@ -124,14 +124,14 @@ export default function Header() {
|
||||
/* Customer: phone icon + label */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title={`Customer - ${user.phone || ""} - Click to sign out`}
|
||||
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">Customer</span>
|
||||
<span className="hidden sm:inline">Khách hàng</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+114
-29
@@ -12,18 +12,95 @@ import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
isInitialized: boolean;
|
||||
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
|
||||
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 [isInitialized, setIsInitialized] = useState(false);
|
||||
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
@@ -33,33 +110,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
|
||||
const isPhone = /^0\d{9}$/.test(username.trim());
|
||||
const role = isPhone ? "customer" : "manager";
|
||||
const login = (username: string, password: string): boolean => {
|
||||
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: username, password, role }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
return { ok: true };
|
||||
} else {
|
||||
console.error("Đăng nhập thất bại:", response.status);
|
||||
return { ok: false, status: response.status };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi kết nối API:", error);
|
||||
return { ok: false };
|
||||
if (authEntry && authEntry.password === password) {
|
||||
setUser(authEntry.user);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -67,14 +129,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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,
|
||||
setUser,
|
||||
isInitialized,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface CartContextValue {
|
||||
decreaseQty: (id: number) => void;
|
||||
removeFromCart: (id: number) => void;
|
||||
setQuantity: (id: number, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "coffee-shop-cart";
|
||||
@@ -91,6 +92,8 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const clearCart = () => setItems([]);
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
@@ -127,6 +130,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
clearCart,
|
||||
}),
|
||||
[items, totalItems, totalPrice],
|
||||
);
|
||||
|
||||
+112
-96
@@ -15,9 +15,9 @@ import type {
|
||||
// ===== SHOP INFORMATION =====
|
||||
export const SHOP_INFO: ShopInfo = {
|
||||
name: "Coffee Shop",
|
||||
tagline: "Rich Flavors – Moments of Relaxation",
|
||||
tagline: "Hương vị đậm đà – Khoảnh khắc thư giãn",
|
||||
logo: "/imgs/logo.png",
|
||||
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
|
||||
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",
|
||||
@@ -25,7 +25,7 @@ export const SHOP_INFO: ShopInfo = {
|
||||
name: "CoffeeShop_Free",
|
||||
password: "coffee2024",
|
||||
},
|
||||
openHours: "07:00 – 22:00 (Monday – Sunday)",
|
||||
openHours: "07:00 – 22:00 (Thứ 2 – Chủ nhật)",
|
||||
};
|
||||
|
||||
// ===== SOCIAL LINKS =====
|
||||
@@ -38,15 +38,15 @@ export const SOCIAL_LINKS: SocialLinks = {
|
||||
// ===== MENU CATEGORIES =====
|
||||
// Each category has a unique FontAwesome icon representing the item type
|
||||
export const MENU_CATEGORIES: MenuCategory[] = [
|
||||
{ id: "all", name: "All", icon: "fa-solid fa-border-all" },
|
||||
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
|
||||
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
|
||||
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
|
||||
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
|
||||
{ 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: "Refreshments / Snacks",
|
||||
name: "Giải Khát / Ăn Vặt",
|
||||
icon: "fa-solid fa-ice-cream",
|
||||
},
|
||||
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
|
||||
@@ -57,182 +57,182 @@ export const MENU_CATEGORIES: MenuCategory[] = [
|
||||
export const MOCK_PRODUCTS: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Black Coffee",
|
||||
name: "Cà Phê Đen",
|
||||
category: "cafe",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
|
||||
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Milk Coffee",
|
||||
name: "Cà Phê Sữa",
|
||||
category: "cafe",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
|
||||
"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: "White Coffee",
|
||||
name: "Bạc Xỉu",
|
||||
category: "cafe",
|
||||
price: 32000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
|
||||
"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: "Egg Coffee",
|
||||
name: "Cà Phê Trứng",
|
||||
category: "cafe",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
|
||||
"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: "Peach Orange Lemongrass Tea",
|
||||
name: "Trà Đào Cam Sả",
|
||||
category: "tra",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
|
||||
"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: "Matcha Green Tea",
|
||||
name: "Trà Xanh Matcha",
|
||||
category: "tra",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
|
||||
"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: "Lychee Jasmine Tea",
|
||||
name: "Trà Vải Hoa Nhài",
|
||||
category: "tra",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
|
||||
"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: "Yogurt with Tapioca Pearls",
|
||||
name: "Sữa Chua Trân Châu",
|
||||
category: "sua-chua",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
|
||||
"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: "Strawberry Yogurt",
|
||||
name: "Sữa Chua Dâu",
|
||||
category: "sua-chua",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
|
||||
"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: "Fresh Orange Juice",
|
||||
name: "Nước Ép Cam",
|
||||
category: "nuoc-ep",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
|
||||
"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: "Watermelon Juice",
|
||||
name: "Nước Ép Dưa Hấu",
|
||||
category: "nuoc-ep",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
|
||||
"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: "Caramel Latte",
|
||||
name: "Latte Caramel",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
|
||||
"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: "Vanilla Latte",
|
||||
name: "Latte Vanilla",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
|
||||
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: "Toasted Butter Bread",
|
||||
name: "Bánh Mì Nướng Bơ",
|
||||
category: "giai-khat",
|
||||
price: 20000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
|
||||
"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: "Caramel Flan",
|
||||
name: "Bánh Flan",
|
||||
category: "giai-khat",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
|
||||
"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: "Black Tapioca Pearls",
|
||||
name: "Trân Châu Đen",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||
"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: "Coffee Jelly",
|
||||
name: "Thạch Cà Phê",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
|
||||
"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: "White Tapioca Pearls",
|
||||
name: "Trân Châu Trắng",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||
"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,
|
||||
},
|
||||
];
|
||||
@@ -241,8 +241,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
export const MOCK_COMBOS: Combo[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Double Coffee Combo",
|
||||
description: "2 black coffees + 2 toasted butter breads, save 15%.",
|
||||
name: "Combo Cà Phê Đôi",
|
||||
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
|
||||
price: 75000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
@@ -253,8 +253,8 @@ export const MOCK_COMBOS: Combo[] = [
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Group Tea Combo",
|
||||
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
|
||||
name: "Combo Trà Sữa Nhóm",
|
||||
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
|
||||
price: 130000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
@@ -265,8 +265,8 @@ export const MOCK_COMBOS: Combo[] = [
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Morning Combo",
|
||||
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
|
||||
name: "Combo Buổi Sáng",
|
||||
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
|
||||
price: 48000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
@@ -354,34 +354,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
|
||||
|
||||
// Weekly revenue (last 12 weeks)
|
||||
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
|
||||
{ label: "Jan/W1", revenue: 8200000, orders: 295 },
|
||||
{ label: "Jan/W2", revenue: 9450000, orders: 340 },
|
||||
{ label: "Jan/W3", revenue: 10100000, orders: 362 },
|
||||
{ label: "Jan/W4", revenue: 8750000, orders: 315 },
|
||||
{ label: "Feb/W1", revenue: 9200000, orders: 330 },
|
||||
{ label: "Feb/W2", revenue: 10500000, orders: 378 },
|
||||
{ label: "Feb/W3", revenue: 11200000, orders: 400 },
|
||||
{ label: "Feb/W4", revenue: 9800000, orders: 352 },
|
||||
{ label: "Mar/W1", revenue: 10400000, orders: 374 },
|
||||
{ label: "Mar/W2", revenue: 11800000, orders: 424 },
|
||||
{ label: "Mar/W3", revenue: 12500000, orders: 448 },
|
||||
{ label: "Mar/W4", revenue: 10900000, orders: 392 },
|
||||
{ label: "T1/W1", revenue: 8200000, orders: 295 },
|
||||
{ label: "T1/W2", revenue: 9450000, orders: 340 },
|
||||
{ label: "T1/W3", revenue: 10100000, orders: 362 },
|
||||
{ label: "T1/W4", revenue: 8750000, orders: 315 },
|
||||
{ label: "T2/W1", revenue: 9200000, orders: 330 },
|
||||
{ label: "T2/W2", revenue: 10500000, orders: 378 },
|
||||
{ label: "T2/W3", revenue: 11200000, orders: 400 },
|
||||
{ label: "T2/W4", revenue: 9800000, orders: 352 },
|
||||
{ label: "T3/W1", revenue: 10400000, orders: 374 },
|
||||
{ label: "T3/W2", revenue: 11800000, orders: 424 },
|
||||
{ label: "T3/W3", revenue: 12500000, orders: 448 },
|
||||
{ label: "T3/W4", revenue: 10900000, orders: 392 },
|
||||
];
|
||||
|
||||
// Monthly revenue (last 12 months)
|
||||
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
|
||||
{ label: "Apr/2025", revenue: 42000000, orders: 1512 },
|
||||
{ label: "May/2025", revenue: 45500000, orders: 1638 },
|
||||
{ label: "Jun/2025", revenue: 48000000, orders: 1728 },
|
||||
{ label: "Jul/2025", revenue: 52000000, orders: 1872 },
|
||||
{ label: "Aug/2025", revenue: 49500000, orders: 1782 },
|
||||
{ label: "Sep/2025", revenue: 46800000, orders: 1685 },
|
||||
{ label: "Oct/2025", revenue: 51200000, orders: 1843 },
|
||||
{ label: "Nov/2025", revenue: 55000000, orders: 1980 },
|
||||
{ label: "Dec/2025", revenue: 62000000, orders: 2232 },
|
||||
{ label: "Jan/2026", revenue: 44000000, orders: 1584 },
|
||||
{ label: "Feb/2026", revenue: 47500000, orders: 1710 },
|
||||
{ label: "Mar/2026", revenue: 53500000, orders: 1926 },
|
||||
{ label: "T4/2025", revenue: 42000000, orders: 1512 },
|
||||
{ label: "T5/2025", revenue: 45500000, orders: 1638 },
|
||||
{ label: "T6/2025", revenue: 48000000, orders: 1728 },
|
||||
{ label: "T7/2025", revenue: 52000000, orders: 1872 },
|
||||
{ label: "T8/2025", revenue: 49500000, orders: 1782 },
|
||||
{ label: "T9/2025", revenue: 46800000, orders: 1685 },
|
||||
{ label: "T10/2025", revenue: 51200000, orders: 1843 },
|
||||
{ label: "T11/2025", revenue: 55000000, orders: 1980 },
|
||||
{ label: "T12/2025", revenue: 62000000, orders: 2232 },
|
||||
{ label: "T1/2026", revenue: 44000000, orders: 1584 },
|
||||
{ label: "T2/2026", revenue: 47500000, orders: 1710 },
|
||||
{ label: "T3/2026", revenue: 53500000, orders: 1926 },
|
||||
];
|
||||
|
||||
// Yearly revenue (last 5 years)
|
||||
@@ -397,7 +397,7 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
|
||||
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
{
|
||||
productId: 12,
|
||||
name: "Caramel Latte",
|
||||
name: "Latte Caramel",
|
||||
category: "latte",
|
||||
unitsSold: 487,
|
||||
revenue: 21915000,
|
||||
@@ -408,7 +408,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 6,
|
||||
name: "Matcha Green Tea",
|
||||
name: "Trà Xanh Matcha",
|
||||
category: "tra",
|
||||
unitsSold: 412,
|
||||
revenue: 16480000,
|
||||
@@ -419,7 +419,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 5,
|
||||
name: "Peach Orange Lemongrass Tea",
|
||||
name: "Trà Đào Cam Sả",
|
||||
category: "tra",
|
||||
unitsSold: 398,
|
||||
revenue: 13930000,
|
||||
@@ -430,7 +430,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 13,
|
||||
name: "Vanilla Latte",
|
||||
name: "Latte Vanilla",
|
||||
category: "latte",
|
||||
unitsSold: 356,
|
||||
revenue: 16020000,
|
||||
@@ -441,7 +441,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 4,
|
||||
name: "Egg Coffee",
|
||||
name: "Cà Phê Trứng",
|
||||
category: "cafe",
|
||||
unitsSold: 340,
|
||||
revenue: 15300000,
|
||||
@@ -452,7 +452,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 2,
|
||||
name: "Milk Coffee",
|
||||
name: "Cà Phê Sữa",
|
||||
category: "cafe",
|
||||
unitsSold: 325,
|
||||
revenue: 9750000,
|
||||
@@ -463,7 +463,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 3,
|
||||
name: "White Coffee",
|
||||
name: "Bạc Xỉu",
|
||||
category: "cafe",
|
||||
unitsSold: 298,
|
||||
revenue: 9536000,
|
||||
@@ -474,7 +474,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 1,
|
||||
name: "Black Coffee",
|
||||
name: "Cà Phê Đen",
|
||||
category: "cafe",
|
||||
unitsSold: 285,
|
||||
revenue: 7125000,
|
||||
@@ -485,7 +485,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 9,
|
||||
name: "Strawberry Yogurt",
|
||||
name: "Sữa Chua Dâu",
|
||||
category: "sua-chua",
|
||||
unitsSold: 267,
|
||||
revenue: 10680000,
|
||||
@@ -496,7 +496,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 10,
|
||||
name: "Fresh Orange Juice",
|
||||
name: "Nước Ép Cam",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 241,
|
||||
revenue: 8435000,
|
||||
@@ -507,7 +507,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 8,
|
||||
name: "Yogurt with Tapioca Pearls",
|
||||
name: "Sữa Chua Trân Châu",
|
||||
category: "sua-chua",
|
||||
unitsSold: 228,
|
||||
revenue: 8664000,
|
||||
@@ -518,7 +518,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 7,
|
||||
name: "Lychee Jasmine Tea",
|
||||
name: "Trà Vải Hoa Nhài",
|
||||
category: "tra",
|
||||
unitsSold: 215,
|
||||
revenue: 8170000,
|
||||
@@ -529,7 +529,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 15,
|
||||
name: "Caramel Flan",
|
||||
name: "Bánh Flan",
|
||||
category: "giai-khat",
|
||||
unitsSold: 198,
|
||||
revenue: 4950000,
|
||||
@@ -540,7 +540,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 11,
|
||||
name: "Watermelon Juice",
|
||||
name: "Nước Ép Dưa Hấu",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 182,
|
||||
revenue: 5460000,
|
||||
@@ -551,7 +551,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 14,
|
||||
name: "Toasted Butter Bread",
|
||||
name: "Bánh Mì Nướng Bơ",
|
||||
category: "giai-khat",
|
||||
unitsSold: 175,
|
||||
revenue: 3500000,
|
||||
@@ -562,7 +562,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 16,
|
||||
name: "Black Tapioca Pearls",
|
||||
name: "Trân Châu Đen",
|
||||
category: "topping",
|
||||
unitsSold: 456,
|
||||
revenue: 4560000,
|
||||
@@ -573,7 +573,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 17,
|
||||
name: "Coffee Jelly",
|
||||
name: "Thạch Cà Phê",
|
||||
category: "topping",
|
||||
unitsSold: 389,
|
||||
revenue: 3890000,
|
||||
@@ -584,7 +584,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
{
|
||||
productId: 18,
|
||||
name: "White Tapioca Pearls",
|
||||
name: "Trân Châu Trắng",
|
||||
category: "topping",
|
||||
unitsSold: 342,
|
||||
revenue: 3420000,
|
||||
@@ -595,6 +595,22 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 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,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== SHIFT / SCHEDULE DATA =====
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
@@ -619,9 +635,9 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
];
|
||||
|
||||
const staffPool = [
|
||||
{ id: 2, name: "Alex Nguyen" },
|
||||
{ id: 3, name: "Binh Tran" },
|
||||
{ id: 4, name: "Cuong Le" },
|
||||
{ id: 2, name: "Nguyễn Văn An" },
|
||||
{ id: 3, name: "Trần Thị Bình" },
|
||||
{ id: 4, name: "Lê Văn Cường" },
|
||||
];
|
||||
|
||||
// Generate shifts from April 6 to April 26, 2026
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Eatery } from "./types";
|
||||
|
||||
export async function gqlFetch<T = Record<string, unknown>>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/eatery/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function addManagerMenuItem(
|
||||
name: string,
|
||||
price: number,
|
||||
): Promise<{ id: string; name: string; price: number } | null> {
|
||||
const data = await gqlFetch<{
|
||||
addMenuItem: { id: string; name: string; price: number } | null;
|
||||
}>(
|
||||
`mutation($name: String!, $price: Float!) {
|
||||
addMenuItem(name: $name, price: $price) { id name price }
|
||||
}`,
|
||||
{ name, price },
|
||||
);
|
||||
return data.addMenuItem;
|
||||
}
|
||||
|
||||
export async function fetchManagerEatery(): Promise<Eatery | null> {
|
||||
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
|
||||
query {
|
||||
eateriesByOwner {
|
||||
id
|
||||
ownerId
|
||||
name
|
||||
isVerified
|
||||
}
|
||||
}
|
||||
`);
|
||||
return data.eateriesByOwner;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Combo, ComboItem, MenuCategory, Product } from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
|
||||
export type ManagerTab = "products" | "combos" | "categories";
|
||||
|
||||
interface ManagerContextType {
|
||||
// Data
|
||||
|
||||
@@ -52,14 +52,6 @@ export interface SocialLinks {
|
||||
website: string;
|
||||
}
|
||||
|
||||
// ===== EATERY (GRAPHQL) TYPES =====
|
||||
export interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
// ===== SHOP (QUÁN NƯỚC) TYPES =====
|
||||
export interface Shop {
|
||||
id: number;
|
||||
|
||||
@@ -19,15 +19,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const gatewayUrl = "http://localhost:32080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${gatewayUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+38
-10
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.2.3",
|
||||
"version": "1.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp",
|
||||
"version": "1.2.3",
|
||||
"version": "1.1.2",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/react": "^19.2.14",
|
||||
"next": "16.1.7",
|
||||
"nextjs": "^0.0.3",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
@@ -27,7 +28,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
@@ -126,6 +127,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1381,6 +1383,7 @@
|
||||
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.3",
|
||||
@@ -2380,6 +2383,7 @@
|
||||
"integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
@@ -2495,9 +2499,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -2515,6 +2519,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2584,6 +2589,7 @@
|
||||
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.2",
|
||||
"@typescript-eslint/types": "8.57.2",
|
||||
@@ -3109,6 +3115,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3731,6 +3738,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5238,6 +5246,7 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5423,6 +5432,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -8394,6 +8404,7 @@
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -10806,6 +10817,7 @@
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11611,11 +11623,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11834,6 +11847,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
|
||||
@@ -11930,6 +11952,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11939,6 +11962,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -12304,6 +12328,7 @@
|
||||
"integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/error": "^4.0.0",
|
||||
@@ -13561,6 +13586,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13786,6 +13812,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14437,6 +14464,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.2.3",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,6 +15,8 @@
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/react": "^19.2.14",
|
||||
"next": "16.1.7",
|
||||
"nextjs": "^0.0.3",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
@@ -29,7 +31,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
|
||||
Generated
+84
-93
@@ -47,7 +47,7 @@ importers:
|
||||
version: 13.1.5(semantic-release@25.0.3(typescript@5.9.3))
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier@3.8.3)
|
||||
version: 6.0.2(prettier@3.8.2)
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
@@ -58,17 +58,17 @@ importers:
|
||||
specifier: 16.1.7
|
||||
version: 16.1.7(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
specifier: ^3.8.2
|
||||
version: 3.8.2
|
||||
prettier-plugin-embed:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
prettier-plugin-groovy:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1(prettier@3.8.3)
|
||||
version: 0.2.1(prettier@3.8.2)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3)
|
||||
version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.2))(prettier@3.8.2)
|
||||
semantic-release:
|
||||
specifier: ^25.0.3
|
||||
version: 25.0.3(typescript@5.9.3)
|
||||
@@ -171,11 +171,11 @@ packages:
|
||||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
'@emnapi/runtime@1.9.2':
|
||||
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
@@ -218,16 +218,12 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
'@humanfs/node@0.16.8':
|
||||
resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
'@humanfs/types@0.15.0':
|
||||
resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
|
||||
'@humanfs/node@0.16.7':
|
||||
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
'@humanwhocodes/module-importer@1.0.1':
|
||||
@@ -1096,8 +1092,8 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
baseline-browser-mapping@2.10.20:
|
||||
resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==}
|
||||
baseline-browser-mapping@2.10.18:
|
||||
resolution: {integrity: sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1175,8 +1171,8 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001788:
|
||||
resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
|
||||
caniuse-lite@1.0.30001787:
|
||||
resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
|
||||
|
||||
chalk@2.4.1:
|
||||
resolution: {integrity: sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==}
|
||||
@@ -1467,8 +1463,8 @@ packages:
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
electron-to-chromium@1.5.340:
|
||||
resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==}
|
||||
electron-to-chromium@1.5.336:
|
||||
resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
@@ -1624,11 +1620,11 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
|
||||
|
||||
eslint-plugin-react-hooks@7.1.1:
|
||||
resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
|
||||
eslint-plugin-react-hooks@7.0.1:
|
||||
resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
|
||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
||||
|
||||
eslint-plugin-react@7.37.5:
|
||||
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
|
||||
@@ -1893,8 +1889,8 @@ packages:
|
||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-tsconfig@4.14.0:
|
||||
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||
get-tsconfig@4.13.7:
|
||||
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
||||
|
||||
git-log-parser@1.2.1:
|
||||
resolution: {integrity: sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==}
|
||||
@@ -1977,8 +1973,8 @@ packages:
|
||||
resolution: {integrity: sha512-L83pBR/oZvQQNjv4kw9aUpTqBxERPiY7B42jsmkt1VDeUaRVhYkEIKzkCqrppjtxHe2EZqzZJzuhMXsWsxYIsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
hasown@2.0.3:
|
||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
@@ -2996,8 +2992,8 @@ packages:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.10:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
postcss@8.5.9:
|
||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -3068,8 +3064,8 @@ packages:
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
prettier@3.8.2:
|
||||
resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3590,8 +3586,8 @@ packages:
|
||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
type-fest@5.6.0:
|
||||
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||
type-fest@5.5.0:
|
||||
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
type-is@1.6.18:
|
||||
@@ -3638,8 +3634,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici@6.25.0:
|
||||
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
|
||||
undici@6.24.1:
|
||||
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.25.0:
|
||||
@@ -3849,7 +3845,7 @@ snapshots:
|
||||
'@actions/http-client@4.0.0':
|
||||
dependencies:
|
||||
tunnel: 0.0.6
|
||||
undici: 6.25.0
|
||||
undici: 6.24.1
|
||||
|
||||
'@actions/io@3.0.2': {}
|
||||
|
||||
@@ -3970,13 +3966,13 @@ snapshots:
|
||||
'@colors/colors@1.5.0':
|
||||
optional: true
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
'@emnapi/runtime@1.9.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -4032,18 +4028,13 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.8':
|
||||
'@humanfs/node@0.16.7':
|
||||
dependencies:
|
||||
'@humanfs/core': 0.19.2
|
||||
'@humanfs/types': 0.15.0
|
||||
'@humanfs/core': 0.19.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
|
||||
'@humanfs/types@0.15.0': {}
|
||||
|
||||
'@humanwhocodes/module-importer@1.0.1': {}
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
@@ -4133,7 +4124,7 @@ snapshots:
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@emnapi/runtime': 1.9.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
@@ -4166,8 +4157,8 @@ snapshots:
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
@@ -4477,10 +4468,10 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
postcss: 8.5.10
|
||||
postcss: 8.5.9
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.2)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/parser': 7.29.2
|
||||
@@ -4490,7 +4481,7 @@ snapshots:
|
||||
lodash-es: 4.18.1
|
||||
minimatch: 9.0.9
|
||||
parse-imports-exports: 0.2.4
|
||||
prettier: 3.8.3
|
||||
prettier: 3.8.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4883,7 +4874,7 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.10.20: {}
|
||||
baseline-browser-mapping@2.10.18: {}
|
||||
|
||||
basic-auth@2.0.1:
|
||||
dependencies:
|
||||
@@ -4933,9 +4924,9 @@ snapshots:
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.20
|
||||
caniuse-lite: 1.0.30001788
|
||||
electron-to-chromium: 1.5.340
|
||||
baseline-browser-mapping: 2.10.18
|
||||
caniuse-lite: 1.0.30001787
|
||||
electron-to-chromium: 1.5.336
|
||||
node-releases: 2.0.37
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
@@ -4979,7 +4970,7 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001788: {}
|
||||
caniuse-lite@1.0.30001787: {}
|
||||
|
||||
chalk@2.4.1:
|
||||
dependencies:
|
||||
@@ -5264,7 +5255,7 @@ snapshots:
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.340: {}
|
||||
electron-to-chromium@1.5.336: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
@@ -5322,7 +5313,7 @@ snapshots:
|
||||
has-property-descriptors: 1.0.2
|
||||
has-proto: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
internal-slot: 1.1.0
|
||||
is-array-buffer: 3.0.5
|
||||
is-callable: 1.2.7
|
||||
@@ -5389,11 +5380,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
|
||||
es-shim-unscopables@1.1.0:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
|
||||
es-to-primitive@1.3.0:
|
||||
dependencies:
|
||||
@@ -5420,7 +5411,7 @@ snapshots:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
|
||||
globals: 16.4.0
|
||||
typescript-eslint: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
@@ -5444,7 +5435,7 @@ snapshots:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
get-tsconfig: 4.14.0
|
||||
get-tsconfig: 4.13.7
|
||||
is-bun-module: 2.0.0
|
||||
stable-hash: 0.0.5
|
||||
tinyglobby: 0.2.16
|
||||
@@ -5477,7 +5468,7 @@ snapshots:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.5
|
||||
@@ -5505,7 +5496,7 @@ snapshots:
|
||||
damerau-levenshtein: 1.0.8
|
||||
emoji-regex: 9.2.2
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
minimatch: 3.1.5
|
||||
@@ -5513,7 +5504,7 @@ snapshots:
|
||||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
|
||||
eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)):
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
@@ -5534,7 +5525,7 @@ snapshots:
|
||||
es-iterator-helpers: 1.3.2
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
estraverse: 5.3.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.5
|
||||
object.entries: 1.1.9
|
||||
@@ -5567,7 +5558,7 @@ snapshots:
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
'@eslint/js': 9.39.4
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.8
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5799,7 +5790,7 @@ snapshots:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formats@1.0.0: {}
|
||||
@@ -5837,7 +5828,7 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
define-properties: 1.2.1
|
||||
functions-have-names: 1.2.3
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
is-callable: 1.2.7
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
@@ -5860,7 +5851,7 @@ snapshots:
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-own-enumerable-property-symbols@3.0.2: {}
|
||||
@@ -5891,7 +5882,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
get-tsconfig@4.14.0:
|
||||
get-tsconfig@4.13.7:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
@@ -6001,7 +5992,7 @@ snapshots:
|
||||
'@babel/runtime': 7.1.2
|
||||
amqplib: 0.5.2
|
||||
|
||||
hasown@2.0.3:
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
@@ -6094,7 +6085,7 @@ snapshots:
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
into-stream@7.0.0:
|
||||
@@ -6137,7 +6128,7 @@ snapshots:
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
|
||||
is-data-view@1.0.2:
|
||||
dependencies:
|
||||
@@ -6192,7 +6183,7 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.2
|
||||
|
||||
is-regexp@1.0.0: {}
|
||||
|
||||
@@ -6588,8 +6579,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@next/env': 16.1.7
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.20
|
||||
caniuse-lite: 1.0.30001788
|
||||
baseline-browser-mapping: 2.10.18
|
||||
caniuse-lite: 1.0.30001787
|
||||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
@@ -6871,7 +6862,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.10:
|
||||
postcss@8.5.9:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -6886,21 +6877,21 @@ snapshots:
|
||||
micro-memoize: 5.1.1
|
||||
package-up: 5.0.0
|
||||
tiny-jsonc: 1.0.2
|
||||
type-fest: 5.6.0
|
||||
type-fest: 5.5.0
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
|
||||
prettier-plugin-groovy@0.2.1(prettier@3.8.3):
|
||||
prettier-plugin-groovy@0.2.1(prettier@3.8.2):
|
||||
dependencies:
|
||||
prettier: 3.8.3
|
||||
prettier: 3.8.2
|
||||
|
||||
prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3):
|
||||
prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.2))(prettier@3.8.2):
|
||||
dependencies:
|
||||
prettier: 3.8.3
|
||||
prettier: 3.8.2
|
||||
optionalDependencies:
|
||||
'@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3)
|
||||
'@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.2)
|
||||
|
||||
prettier@3.8.3: {}
|
||||
prettier@3.8.2: {}
|
||||
|
||||
pretty-ms@9.3.0:
|
||||
dependencies:
|
||||
@@ -6973,14 +6964,14 @@ snapshots:
|
||||
dependencies:
|
||||
find-up-simple: 1.0.1
|
||||
read-pkg: 10.1.0
|
||||
type-fest: 5.6.0
|
||||
type-fest: 5.5.0
|
||||
|
||||
read-pkg@10.1.0:
|
||||
dependencies:
|
||||
'@types/normalize-package-data': 2.4.4
|
||||
normalize-package-data: 8.0.0
|
||||
parse-json: 8.3.0
|
||||
type-fest: 5.6.0
|
||||
type-fest: 5.5.0
|
||||
unicorn-magic: 0.4.0
|
||||
|
||||
read-pkg@9.0.1:
|
||||
@@ -7559,7 +7550,7 @@ snapshots:
|
||||
|
||||
type-fest@4.41.0: {}
|
||||
|
||||
type-fest@5.6.0:
|
||||
type-fest@5.5.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
@@ -7626,7 +7617,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@6.25.0: {}
|
||||
undici@6.24.1: {}
|
||||
|
||||
undici@7.25.0: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user