resolve conflict
This commit is contained in:
@@ -11,6 +11,11 @@
|
||||
"upgradePackages": true
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
}
|
||||
}
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Release package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev-*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.set_output.outputs.version }}
|
||||
steps:
|
||||
- name: Calculate and Export Target IP
|
||||
run: |
|
||||
# 1. Lấy IP hiện tại và tính toán IP đích (đổi octet cuối thành .1)
|
||||
RUNNER_IP=$(hostname -I | awk '{print $1}')
|
||||
CALCULATED_IP=$(echo $RUNNER_IP | cut -d'.' -f1-3).1
|
||||
|
||||
# 2. Đưa vào biến môi trường toàn cục của Job
|
||||
echo "TARGET_IP=${CALCULATED_IP}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup SSH Key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
ssh-keyscan -p 222 -t ed25519 $TARGET_IP >> ~/.ssh/known_hosts
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Update dependencies
|
||||
run: pnpm update
|
||||
|
||||
- name: Format code
|
||||
run: pnpm format
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITEA_USER: ${{ github.actor }}
|
||||
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: pnpm release
|
||||
|
||||
- name: Set Version Output
|
||||
id: set_output
|
||||
run: |
|
||||
# Đọc version hiện tại trong package.json sau khi release
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit & Push Changes
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git config --global user.name "gitea-actions"
|
||||
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
||||
|
||||
REPO_NAME="${{ gitea.repository }}"
|
||||
|
||||
git remote set-url origin ssh://git@$TARGET_IP:222/${REPO_NAME}.git
|
||||
|
||||
git rm -r --cached .
|
||||
git add .
|
||||
git commit -m "chore: release [ci skip]"
|
||||
|
||||
git push
|
||||
fi
|
||||
|
||||
build-and-push:
|
||||
needs: release
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.demonkernel.io.vn
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Prepare Docker Metadata
|
||||
id: meta
|
||||
run: |
|
||||
# Lowercase Repository
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Lấy version từ job release và lowercase nó (cho các bản prerelease)
|
||||
RAW_VERSION="${{ needs.release.outputs.new_version }}"
|
||||
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
/.pnpm-store
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -19,6 +20,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/release.zip
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"importOrder": ["^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"proseWrap": "always",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-groovy",
|
||||
"prettier-plugin-embed",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"importOrderParserPlugins": ["typescript", "decorators-legacy", "jsx"],
|
||||
"embeddedMarkdownComments": ["tw", "tx"],
|
||||
"printWidth": 80,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"tailwindAttributes": ["/.*(C|c)lassName/"]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# Coffee Shop Frontend
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,26 +55,32 @@ Dành cho khách hàng:
|
||||
## Cách Chạy Dự Án
|
||||
|
||||
### Yêu cầu hệ thống
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm (khuyến nghị) hoặc npm
|
||||
|
||||
### Cài đặt
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Dev
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Mở trình duyệt tại http://localhost:3000
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
@@ -137,7 +144,7 @@ frondend/
|
||||
## Công Nghệ Sử Dụng
|
||||
|
||||
| Công nghệ | Phiên bản | Mục đích |
|
||||
|-----------|-----------|----------|
|
||||
| ------------ | --------- | ------------------------------------- |
|
||||
| Next.js | 16.1.7 | React Framework (App Router) |
|
||||
| React | 19.2.3 | Thư viện UI |
|
||||
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
|
||||
|
||||
@@ -2,63 +2,40 @@
|
||||
|
||||
## Completed Features & Implementations
|
||||
|
||||
### A. Documentation
|
||||
- [x] README.md - Updated with current features (Auth, Cart, Feed, etc.)
|
||||
- [x] COMPONENTS.md - Added CartFab, AuthContext, CartContext, MenuContext sections
|
||||
- [x] APP.md - New file: Detailed app routes, pages, layouts
|
||||
- [x] LIB.md - New file: Types, Constants, Contexts documentation
|
||||
- [x] LAYOUTS.md - New file: Header & Footer detailed documentation
|
||||
- [x] WORKFLOW.md - Architecture and workflow documentation
|
||||
### A. Dead Code Removed
|
||||
|
||||
### B. Authentication System
|
||||
- [x] lib/auth-context.tsx - Created Auth context with login/logout/register
|
||||
- [x] lib/types.ts - User type with roles (manager, staff, customer)
|
||||
- [x] app/(main)/login/page.tsx - Login page with form validation
|
||||
- [x] app/(main)/register/page.tsx - Register page with OTP verification
|
||||
- [x] localStorage integration - Persist user across sessions
|
||||
- [x] Mock auth database - Pre-configured accounts for testing
|
||||
- [x] lib/constants.ts - Removed unused NAV_LINKS export
|
||||
- [x] lib/types.ts - Removed unused NavLink interface
|
||||
- [x] components/Navbar.tsx - Removed trivial handleClick wrapper; inlined
|
||||
onCategoryChange call
|
||||
- [x] components/Navbar.tsx - Removed unused Link import
|
||||
|
||||
### C. Shopping Cart System
|
||||
- [x] lib/cart-context.tsx - Cart context with add/remove/update operations
|
||||
- [x] Cart persistence - localStorage integration
|
||||
- [x] CartItem interface - With id, name, description, price, quantity
|
||||
- [x] Cart operations - addToCart, increaseQty, decreaseQty, removeFromCart, setQuantity
|
||||
- [x] Computed totals - totalItems and totalPrice memoized
|
||||
### B. Bugs / Inaccuracies Fixed
|
||||
|
||||
### D. Menu/Category System
|
||||
- [x] lib/menu-context.tsx - MenuContext for shared category state
|
||||
- [x] Shared state across components - Header mobile menu + Navbar sidebar sync
|
||||
- [x] Category filtering - Products filtered by active category
|
||||
- [x] layouts/header.tsx - Fixed JSDoc: 3-column -> 2-column layout (no center
|
||||
section exists)
|
||||
- [x] app/page.tsx - Added available !== false filter to product list
|
||||
- [x] app/page.tsx - Fixed setState-in-effect lint error: moved initial sidebar
|
||||
state to lazy useState initializer
|
||||
- [x] next.config.ts - Added explanatory JSDoc comment
|
||||
|
||||
### E. Product Pages
|
||||
- [x] app/(main)/page.tsx - Main page with sidebar + product grid
|
||||
- [x] Responsive product grid - 1-5 columns depending on sidebar & screen size
|
||||
- [x] Product search - Search by name and description
|
||||
- [x] Category filter - Sidebar collapsible navigation
|
||||
- [x] Mobile category menu - Scrollable tabs on < md breakpoint
|
||||
- [x] Empty states - Messaging when no products match filters
|
||||
- [x] CartProduct component - Product card with image, price, buy button
|
||||
### C. Documentation Updated
|
||||
|
||||
### F. Feed/Discovery Page
|
||||
- [x] app/(feed)/feed/page.tsx - Shop discovery page
|
||||
- [x] Shop grid - Responsive 1/2/3 column layout
|
||||
- [x] Search by name and address - Sticky bottom filter bar
|
||||
- [x] Shop cards - Image, name, address, view menu button
|
||||
- [x] Empty state - Message when no shops match filters
|
||||
- [x] MOCK_SHOPS constant - 5 sample coffee shops
|
||||
- [x] README.md - Fixed file structure tree, removed SCSS, fixed dark mode note,
|
||||
updated tech table
|
||||
- [x] components/COMPONENTS.md - Fixed CartProduct styling (was outdated
|
||||
w-64/text-red-500/bg-blue-600); added Navbar, Header, Footer sections
|
||||
|
||||
### G. Header & Footer
|
||||
- [x] layouts/header.tsx - Sticky header with brand and auth demo
|
||||
- [x] layouts/footer.tsx - Footer with shop info, social links, WiFi
|
||||
- [x] Responsive layouts - Mobile/tablet/desktop optimized
|
||||
- [x] Auth state display - Guest/Manager/Staff cycling demo
|
||||
### D. New Documentation Created
|
||||
|
||||
### H. Code Quality & Optimization
|
||||
- [x] Dead code removed - Unused imports and exports cleaned
|
||||
- [x] Lint passed - 0 errors, 0 warnings
|
||||
- [x] Build passed - TypeScript clean, all pages compile
|
||||
- [x] Type safety - Full TypeScript coverage
|
||||
- [x] React best practices - Hooks, memoization, context patterns
|
||||
- [x] WORKFLOW.md - Architecture, data flow, design token system, how-to guides,
|
||||
dev workflow
|
||||
|
||||
### E. Mini-test Results
|
||||
|
||||
- [x] npm run lint - PASSED (0 errors, 0 warnings)
|
||||
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
|
||||
pages generated)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+63
-58
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -13,7 +13,11 @@ export default function LoginPage() {
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({ username: "", password: "", general: "" });
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const validate = (): boolean => {
|
||||
@@ -47,20 +51,21 @@ export default function LoginPage() {
|
||||
if (success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setErrors({ username: "", password: "", general: "Tên đăng nhập hoặc mật khẩu không đúng" });
|
||||
setErrors({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8 bg-background"
|
||||
>
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{/* Login Form Card */}
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg p-8">
|
||||
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<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}
|
||||
@@ -70,7 +75,7 @@ export default function LoginPage() {
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-(--color-primary-dark) mb-1">
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
@@ -80,7 +85,7 @@ export default function LoginPage() {
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600 flex items-center gap-2">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{errors.general}</span>
|
||||
</div>
|
||||
@@ -88,14 +93,16 @@ export default function LoginPage() {
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
{/* Username Input */}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Tên đăng nhập
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-user absolute left-4 top-1/2 -translate-y-1/2 text-(--color-text-muted) hidden lg:block"></i>
|
||||
<i className="fa-solid fa-user absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -105,19 +112,11 @@ export default function LoginPage() {
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
}}
|
||||
placeholder="admin / số điện thoại / tên nhân viên"
|
||||
className={`
|
||||
w-full px-10 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-foreground
|
||||
placeholder:text-(--color-text-muted)
|
||||
focus:border-(--color-primary) focus:ring-2
|
||||
focus:ring-(--color-primary) focus:ring-opacity-20
|
||||
transition-all duration-150
|
||||
${errors.username ? "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.username ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.username}
|
||||
</p>
|
||||
@@ -126,11 +125,14 @@ export default function LoginPage() {
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mật khẩu
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-lock absolute left-4 top-1/2 -translate-y-1/2 text-(--color-text-muted) hidden lg:block"></i>
|
||||
<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"}
|
||||
@@ -140,27 +142,21 @@ export default function LoginPage() {
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
placeholder="Nhập mật khẩu"
|
||||
className={`
|
||||
w-full px-10 lg:pl-11 pr-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-foreground
|
||||
placeholder:text-(--color-text-muted)
|
||||
focus:border-(--color-primary) focus:ring-2
|
||||
focus:ring-(--color-primary) focus:ring-opacity-20
|
||||
transition-all duration-150
|
||||
${errors.password ? "border-red-400" : "border-(--color-border)"}
|
||||
`}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 pr-11 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
||||
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>
|
||||
<i
|
||||
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.password}
|
||||
</p>
|
||||
@@ -172,10 +168,7 @@ export default function LoginPage() {
|
||||
{/* Login Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-xl font-semibold text-white
|
||||
bg-(--color-primary) hover:bg-(--color-primary-dark)
|
||||
active:scale-98 transition-all duration-150
|
||||
border-none cursor-pointer"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
@@ -183,12 +176,7 @@ export default function LoginPage() {
|
||||
{/* Register Button */}
|
||||
<Link
|
||||
href="/register"
|
||||
className="w-full py-3 rounded-xl font-semibold
|
||||
bg-white text-(--color-primary)
|
||||
border-2 border-(--color-primary)
|
||||
hover:bg-(--color-primary) hover:text-white
|
||||
active:scale-98 transition-all duration-150
|
||||
flex items-center justify-center no-underline"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Đăng ký tài khoản
|
||||
</Link>
|
||||
@@ -196,12 +184,29 @@ export default function LoginPage() {
|
||||
</form>
|
||||
|
||||
{/* Demo Credentials Info */}
|
||||
<div className="mt-6 p-4 bg-background rounded-lg">
|
||||
<p className="text-xs text-(--color-text-muted) mb-2 font-semibold">Tài khoản demo:</p>
|
||||
<ul className="text-xs text-(--color-text-muted) space-y-1">
|
||||
<li>• Quản lý: <code className="bg-white px-1.5 py-0.5 rounded">admin / admin</code></li>
|
||||
<li>• Nhân viên: <code className="bg-white px-1.5 py-0.5 rounded">Nguyễn Văn An / Nguyễn Văn An</code></li>
|
||||
<li>• Khách hàng: <code className="bg-white px-1.5 py-0.5 rounded">0987654321 / user1</code></li>
|
||||
<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>
|
||||
|
||||
+19
-40
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import CartProduct from "@/components/CartProduct";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Main page — sidebar + product grid layout.
|
||||
@@ -56,7 +56,8 @@ export default function Home() {
|
||||
* p.available defaults to true when undefined (opt-in unavailability). */
|
||||
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
||||
const isAvailable = p.available !== false;
|
||||
const matchesCategory = activeCategory === "all" || p.category === activeCategory;
|
||||
const matchesCategory =
|
||||
activeCategory === "all" || p.category === activeCategory;
|
||||
const matchesSearch =
|
||||
searchQuery.trim() === "" ||
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -79,9 +80,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
/* Outer wrapper: flex row, align-items: flex-start so sidebar sticks */
|
||||
<div
|
||||
className="flex items-start bg-background min-h-[calc(100vh-var(--spacing-header-height))]"
|
||||
>
|
||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
||||
{/* ── Sidebar ── */}
|
||||
<Navbar
|
||||
isOpen={isSidebarOpen}
|
||||
@@ -91,37 +90,28 @@ export default function Home() {
|
||||
/>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="flex-1 min-w-0 px-4 py-6 md:px-6 lg:px-8">
|
||||
|
||||
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
|
||||
{/* ── Section heading + search bar ── */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
|
||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
{/* Title + count */}
|
||||
<div className="shrink-0">
|
||||
<h2 className="text-xl font-bold text-foreground">
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
{activeCategoryLabel}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{filteredProducts.length} món
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<i
|
||||
className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2
|
||||
text-sm text-(--color-text-muted) pointer-events-none"
|
||||
></i>
|
||||
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm món..."
|
||||
className="w-full pl-9 pr-9 py-2 text-sm rounded-xl border outline-none
|
||||
bg-card text-foreground
|
||||
border-border placeholder:text-muted-foreground
|
||||
focus:border-primary focus:ring-2
|
||||
focus:ring-primary focus:ring-opacity-20
|
||||
transition-all duration-150"
|
||||
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
||||
/>
|
||||
{/* Clear button */}
|
||||
{searchQuery && (
|
||||
@@ -129,9 +119,7 @@ export default function Home() {
|
||||
onClick={() => setSearchQuery("")}
|
||||
title="Xóa tìm kiếm"
|
||||
aria-label="Xóa tìm kiếm"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-text-muted)
|
||||
hover:text-(--color-primary) transition-colors duration-150
|
||||
cursor-pointer border-none bg-transparent p-0"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
@@ -140,7 +128,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* ── Mobile category menu — visible only on < md, below search, above products ── */}
|
||||
<div className="md:hidden sticky top-18 pt-2 bg-background z-50 -mx-4 px-4 overflow-x-auto mb-4">
|
||||
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
|
||||
<div className="flex items-center gap-1.5 pb-1">
|
||||
{MENU_CATEGORIES.map((cat) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
@@ -148,22 +136,14 @@ export default function Home() {
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-2 rounded-xl
|
||||
text-sm font-medium whitespace-nowrap shrink-0
|
||||
cursor-pointer border-none transition-all duration-150
|
||||
${
|
||||
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
<i
|
||||
className={`
|
||||
${cat.icon} text-sm shrink-0
|
||||
${isActive ? "text-white" : "text-(--color-primary)"}
|
||||
`}
|
||||
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
<span>{cat.name}</span>
|
||||
</button>
|
||||
@@ -189,7 +169,7 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4 text-(--color-text-muted)">
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
|
||||
<p className="text-base font-medium">
|
||||
{searchQuery
|
||||
@@ -199,8 +179,7 @@ export default function Home() {
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="text-sm text-(--color-primary) hover:underline cursor-pointer
|
||||
border-none bg-transparent"
|
||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
||||
>
|
||||
Xóa tìm kiếm
|
||||
</button>
|
||||
|
||||
+42
-27
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
@@ -17,12 +17,14 @@ export default function PaymentPage() {
|
||||
} = useCart();
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-2xl mx-auto w-full px-4 md:px-6 lg:px-8 py-6 md:py-8">
|
||||
<div className="flex flex-col xl:flex-row gap-6">
|
||||
<section className="flex-1 min-w-0">
|
||||
<div className="rounded-2xl border border-(--color-border-light) bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-(--color-border-light)">
|
||||
<h1 className="text-lg md:text-xl font-bold text-foreground">Trang thanh toán</h1>
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
||||
<div className="flex flex-col gap-6 xl:flex-row">
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)">
|
||||
<div className="border-b border-(--color-border-light) px-4 py-3">
|
||||
<h1 className="text-foreground text-lg font-bold md:text-xl">
|
||||
Trang thanh toán
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
@@ -38,24 +40,31 @@ export default function PaymentPage() {
|
||||
<th className="px-4 py-3 font-semibold">Giá tiền</th>
|
||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||
<th className="px-4 py-3 font-semibold">Số lượng</th>
|
||||
<th className="px-4 py-3 font-semibold text-right">Xóa</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">
|
||||
Xóa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-t border-(--color-border-light)">
|
||||
<td className="px-4 py-3 font-medium text-foreground">{item.name}</td>
|
||||
<td className="px-4 py-3 text-(--color-primary) font-semibold">
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-(--color-border-light)"
|
||||
>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-(--color-text-muted) max-w-70">
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{item.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(item.id)}
|
||||
className="w-8 h-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
className="h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Giảm số lượng ${item.name}`}
|
||||
>
|
||||
-
|
||||
@@ -64,13 +73,15 @@ export default function PaymentPage() {
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) => setQuantity(item.id, Number(e.target.value))}
|
||||
className="w-16 h-8 text-center rounded-lg border border-(--color-border) bg-transparent"
|
||||
title='Nhập số lượng'
|
||||
onChange={(e) =>
|
||||
setQuantity(item.id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Nhập số lượng"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(item.id)}
|
||||
className="w-8 h-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
className="h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Tăng số lượng ${item.name}`}
|
||||
>
|
||||
+
|
||||
@@ -80,10 +91,12 @@ export default function PaymentPage() {
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-red-500 px-3 py-2 text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
<i className="fa-solid fa-trash"></i>
|
||||
<span className="hidden lg:inline">Xóa sản phẩm</span>
|
||||
<span className="hidden lg:inline">
|
||||
Xóa sản phẩm
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -95,18 +108,20 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="xl:w-85 shrink-0">
|
||||
<div className="sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) bg-card p-4 md:p-5">
|
||||
<h2 className="text-lg font-bold mb-4">Hóa đơn</h2>
|
||||
<aside className="shrink-0 xl:w-85">
|
||||
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2>
|
||||
|
||||
<div className="flex items-center justify-between pb-4 border-b border-(--color-border-light)">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
||||
<span className="text-(--color-text-muted)">Tổng cộng</span>
|
||||
<span className="text-xl font-bold text-(--color-primary)">{formatPrice(totalPrice)}</span>
|
||||
<span className="text-xl font-bold text-(--color-primary)">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2.5 bg-(--color-primary) text-white hover:bg-(--color-primary-dark) transition-colors cursor-pointer"
|
||||
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-(--color-primary) px-3 py-2.5 text-white transition-colors hover:bg-(--color-primary-dark)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-money-bill-wave"></i>
|
||||
@@ -114,7 +129,7 @@ export default function PaymentPage() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2.5 border border-(--color-border) text-foreground hover:bg-(--color-border-light) transition-colors cursor-pointer"
|
||||
className="text-foreground inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-(--color-border) px-3 py-2.5 transition-colors hover:bg-(--color-border-light)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-qrcode"></i>
|
||||
@@ -123,7 +138,7 @@ export default function PaymentPage() {
|
||||
|
||||
<Link href="/">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2.5 border border-(--color-border) text-foreground hover:bg-(--color-border-light) transition-colors cursor-pointer"
|
||||
className="text-foreground inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-(--color-border) px-3 py-2.5 transition-colors hover:bg-(--color-border-light)"
|
||||
type="button"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-rotate-left"></i>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
// Static OTP for demo (in production, this would be sent via SMS)
|
||||
const DEMO_OTP = "123456";
|
||||
@@ -36,7 +36,10 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({ ...errors, phone: "Số điện thoại không hợp lệ (VD: 0987654321)" });
|
||||
setErrors({
|
||||
...errors,
|
||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,15 +73,12 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8 bg-background"
|
||||
>
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{/* Register Form Card */}
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg p-8">
|
||||
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<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}
|
||||
@@ -88,29 +88,35 @@ export default function RegisterPage() {
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-(--color-primary-dark) mb-1">
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone" ? "Đăng ký tài khoản khách hàng" : "Xác thực số điện thoại"}
|
||||
{step === "phone"
|
||||
? "Đăng ký tài khoản khách hàng"
|
||||
: "Xác thực số điện thoại"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
step === "phone"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-(--color-accent-light) text-(--color-primary-dark)"
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-(--color-border)"></div>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
|
||||
<div className="h-0.5 w-12 bg-(--color-border)"></div>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
step === "otp"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,14 +124,16 @@ export default function RegisterPage() {
|
||||
{/* Phone Step */}
|
||||
{step === "phone" && (
|
||||
<form onSubmit={handlePhoneSubmit} className="space-y-5">
|
||||
|
||||
{/* Phone Input */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Số điện thoại
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-phone absolute left-4 top-1/2 -translate-y-1/2 text-(--color-text-muted) hidden lg:block"></i>
|
||||
<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"
|
||||
@@ -135,19 +143,11 @@ export default function RegisterPage() {
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
className={`
|
||||
w-full px-10 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-foreground
|
||||
placeholder:text-(--color-text-muted)
|
||||
focus:border-(--color-primary) focus:ring-2
|
||||
focus:ring-(--color-primary) focus:ring-opacity-20
|
||||
transition-all duration-150
|
||||
${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 && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.phone}
|
||||
</p>
|
||||
@@ -162,10 +162,7 @@ export default function RegisterPage() {
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-xl font-semibold text-white
|
||||
bg-(--color-primary) hover:bg-(--color-primary-dark)
|
||||
active:scale-98 transition-all duration-150
|
||||
border-none cursor-pointer"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Tiếp tục
|
||||
</button>
|
||||
@@ -173,12 +170,7 @@ export default function RegisterPage() {
|
||||
{/* Back to Login */}
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full py-3 rounded-xl font-semibold
|
||||
bg-white text-(--color-primary)
|
||||
border-2 border-(--color-primary)
|
||||
hover:bg-(--color-primary) hover:text-white
|
||||
active:scale-98 transition-all duration-150
|
||||
flex items-center justify-center no-underline"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
@@ -189,25 +181,30 @@ export default function RegisterPage() {
|
||||
{/* OTP Step */}
|
||||
{step === "otp" && (
|
||||
<form onSubmit={handleOtpSubmit} className="space-y-5">
|
||||
|
||||
{/* Info Message */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="mb-2 text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Demo OTP: <code className="bg-white px-2 py-1 rounded font-mono">{DEMO_OTP}</code>
|
||||
Demo OTP:{" "}
|
||||
<code className="rounded bg-white px-2 py-1 font-mono">
|
||||
{DEMO_OTP}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label htmlFor="otp" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
<label
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mã OTP
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-key absolute left-4 top-1/2 -translate-y-1/2 text-(--color-text-muted) hidden lg:block"></i>
|
||||
<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"
|
||||
@@ -218,19 +215,11 @@ export default function RegisterPage() {
|
||||
}}
|
||||
placeholder="Nhập mã OTP"
|
||||
maxLength={6}
|
||||
className={`
|
||||
w-full px-4 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-foreground text-center text-lg tracking-widest
|
||||
placeholder:text-(--color-text-muted) placeholder:text-sm placeholder:tracking-normal
|
||||
focus:border-(--color-primary) focus:ring-2
|
||||
focus:ring-(--color-primary) focus:ring-opacity-20
|
||||
transition-all duration-150
|
||||
${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 && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.otp}
|
||||
</p>
|
||||
@@ -242,10 +231,7 @@ export default function RegisterPage() {
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-xl font-semibold text-white
|
||||
bg-(--color-primary) hover:bg-(--color-primary-dark)
|
||||
active:scale-98 transition-all duration-150
|
||||
border-none cursor-pointer"
|
||||
className="w-full cursor-pointer rounded-xl border-none bg-(--color-primary) py-3 font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-98"
|
||||
>
|
||||
Hoàn tất đăng ký
|
||||
</button>
|
||||
@@ -254,11 +240,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToPhone}
|
||||
className="w-full py-3 rounded-xl font-semibold
|
||||
bg-white text-(--color-primary)
|
||||
border-2 border-(--color-primary)
|
||||
hover:bg-(--color-primary) hover:text-white
|
||||
active:scale-98 transition-all duration-150"
|
||||
className="w-full rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Thay đổi số điện thoại
|
||||
</button>
|
||||
@@ -269,7 +251,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="text-sm text-(--color-text-muted) cursor-not-allowed"
|
||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
||||
>
|
||||
Gửi lại mã OTP (60s)
|
||||
</button>
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
import CartFab from "@/components/CartFab";
|
||||
import Footer from "@/layouts/footer";
|
||||
import Header from "@/layouts/header";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
@@ -40,7 +44,7 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} flex min-h-screen flex-col antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
{children}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { MenuProvider } from "@/lib/menu-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
import { MenuProvider } from "@/lib/menu-context";
|
||||
|
||||
/**
|
||||
* Client-side providers wrapper.
|
||||
|
||||
+23
-18
@@ -1,18 +1,20 @@
|
||||
# Components Documentation
|
||||
|
||||
> Whenever you create a new component in the components/ directory, automatically append a new section following the template below.
|
||||
> Whenever you create a new component in the components/ directory,
|
||||
> automatically append a new section following the template below.
|
||||
|
||||
---
|
||||
|
||||
## CartProduct
|
||||
|
||||
**File:** components/CartProduct.tsx
|
||||
**Description:** Product card component. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full), not the card itself.
|
||||
**File:** components/CartProduct.tsx **Description:** Product card component.
|
||||
Displays product image, name, description, formatted price, and a Buy button.
|
||||
Width is controlled by the parent grid (w-full), not the card itself.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| ----------- | ---------------- | -------- | ------------ | ----------------------------------------------------------- |
|
||||
| image | string | yes | - | URL/path to product image (Next.js Image) |
|
||||
| imageAlt | string | no | Anh san pham | Alt text for accessibility |
|
||||
| productName | string | yes | - | Product display name |
|
||||
@@ -22,13 +24,15 @@
|
||||
|
||||
### Internal Logic
|
||||
|
||||
- formattedPrice: number -> toLocaleString(vi-VN, { style: currency, currency: VND })
|
||||
- Image fallback: fa-solid fa-mug-hot icon shown behind image; if image fails onError hides the img element
|
||||
- formattedPrice: number -> toLocaleString(vi-VN, { style: currency, currency:
|
||||
VND })
|
||||
- Image fallback: fa-solid fa-mug-hot icon shown behind image; if image fails
|
||||
onError hides the img element
|
||||
|
||||
### Styling (CSS variables)
|
||||
|
||||
| Element | Key classes |
|
||||
|---------|-------------|
|
||||
| ------------ | ------------------------------------------------------------------ |
|
||||
| Card wrapper | flex flex-col w-full rounded-2xl, shadow uses --color-shadow-sm/md |
|
||||
| Image area | relative w-full h-36, bg --color-border-light |
|
||||
| Product name | font-bold text-sm, color --color-text-primary, line-clamp-1 |
|
||||
@@ -52,13 +56,13 @@
|
||||
|
||||
## Navbar
|
||||
|
||||
**File:** components/Navbar.tsx
|
||||
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header.
|
||||
**File:** components/Navbar.tsx **Description:** Left sidebar with collapsible
|
||||
category filter. Sticky below header, full viewport height minus header.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| ---------------- | -------------------- | -------- | --------- | ------------------------------------------------- |
|
||||
| isOpen | boolean | yes | - | true = expanded (240px), false = collapsed (64px) |
|
||||
| onToggle | () => void | yes | - | Toggle expand/collapse |
|
||||
| activeCategory | string | no | all | Currently selected category id |
|
||||
@@ -75,7 +79,7 @@
|
||||
### Styling
|
||||
|
||||
| Element | Key classes |
|
||||
|---------|-------------|
|
||||
| ------------- | ----------------------------------------------------------- |
|
||||
| Aside | sticky, border-r --color-border, bg --color-bg-sidebar |
|
||||
| Toggle button | w-8 h-8 rounded-lg, hover bg --color-border-light |
|
||||
| Active item | bg --color-primary text-white shadow-sm |
|
||||
@@ -92,8 +96,9 @@
|
||||
|
||||
## Header (layouts/header.tsx)
|
||||
|
||||
**File:** layouts/header.tsx
|
||||
**Description:** Sticky top bar. 2-column layout: Brand (left) + Auth button (right). Auth cycles Guest -> Manager -> Staff -> Guest for UI demo.
|
||||
**File:** layouts/header.tsx **Description:** Sticky top bar. 2-column layout:
|
||||
Brand (left) + Auth button (right). Auth cycles Guest -> Manager -> Staff ->
|
||||
Guest for UI demo.
|
||||
|
||||
### Props
|
||||
|
||||
@@ -102,13 +107,13 @@ None - reads SHOP_INFO and MOCK_USERS from lib/constants directly.
|
||||
### Internal State
|
||||
|
||||
| State | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| ----- | ------------ | ------------------------------- |
|
||||
| user | User or null | Current demo user. null = guest |
|
||||
|
||||
### Auth States
|
||||
|
||||
| State | Appearance |
|
||||
|-------|------------|
|
||||
| ------------ | ------------------------------------- |
|
||||
| Guest (null) | Brown primary button, Dang nhap label |
|
||||
| Manager | Gold/caramel badge with user-tie icon |
|
||||
| Staff | Avatar circle + name, bordered button |
|
||||
@@ -130,8 +135,8 @@ None - reads SHOP_INFO and MOCK_USERS from lib/constants directly.
|
||||
|
||||
## Footer (layouts/footer.tsx)
|
||||
|
||||
**File:** layouts/footer.tsx
|
||||
**Description:** Site footer with 12-column grid. 3 sections: Brand info, Social links, WiFi card.
|
||||
**File:** layouts/footer.tsx **Description:** Site footer with 12-column grid. 3
|
||||
sections: Brand info, Social links, WiFi card.
|
||||
|
||||
### Props
|
||||
|
||||
@@ -140,7 +145,7 @@ None - reads SHOP_INFO and SOCIAL_LINKS from lib/constants directly.
|
||||
### Layout
|
||||
|
||||
| Section | Mobile | md | lg/xl |
|
||||
|---------|--------|----|-------|
|
||||
| ------------- | ----------- | ---------- | ------------ |
|
||||
| Brand info | col-span-12 | col-span-6 | col-span-8/6 |
|
||||
| Social + WiFi | col-span-12 | col-span-6 | col-span-4/6 |
|
||||
|
||||
|
||||
+3
-12
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CartFab() {
|
||||
const { totalItems } = useCart();
|
||||
@@ -12,19 +12,10 @@ export default function CartFab() {
|
||||
<Link
|
||||
href="/payment"
|
||||
aria-label="Đi đến trang thanh toán"
|
||||
className="fixed right-5 bottom-6 z-70 w-14 h-14 rounded-full
|
||||
bg-(--color-primary) text-white shadow-xl
|
||||
flex items-center justify-center
|
||||
hover:bg-(--color-primary-dark)
|
||||
active:scale-95 transition-all duration-150"
|
||||
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-cart-shopping text-lg"></i>
|
||||
<span
|
||||
className="absolute -top-1.5 -right-1.5 min-w-6 h-6 px-1.5
|
||||
rounded-full bg-(--color-accent) text-(--color-primary-dark)
|
||||
text-xs font-bold flex items-center justify-center
|
||||
border-2 border-white"
|
||||
>
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-6 min-w-6 items-center justify-center rounded-full border-2 border-white bg-(--color-accent) px-1.5 text-xs font-bold text-(--color-primary-dark)">
|
||||
{totalItems}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -35,18 +35,11 @@ export default function CartProduct({
|
||||
: price;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full rounded-2xl overflow-hidden
|
||||
bg-(--color-bg-card) border border-(--color-border-light)
|
||||
shadow-[0_2px_8px_var(--color-shadow-sm)]
|
||||
hover:shadow-[0_6px_20px_var(--color-shadow-md)]
|
||||
hover:-translate-y-0.5
|
||||
transition-all duration-250 cursor-default"
|
||||
>
|
||||
<div className="flex w-full cursor-default flex-col overflow-hidden rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) shadow-[0_2px_8px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-0.5 hover:shadow-[0_6px_20px_var(--color-shadow-md)]">
|
||||
{/* ── Image area ── */}
|
||||
<div className="relative w-full h-36 bg-(--color-border-light) shrink-0 overflow-hidden">
|
||||
<div className="relative h-36 w-full shrink-0 overflow-hidden bg-(--color-border-light)">
|
||||
{/* Fallback icon (shown when image fails or is missing) */}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl text-(--color-border) z-0">
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center text-4xl text-(--color-border)">
|
||||
<i className="fa-solid fa-mug-hot"></i>
|
||||
</div>
|
||||
{/* Product image */}
|
||||
@@ -54,7 +47,7 @@ export default function CartProduct({
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
className="object-cover z-1"
|
||||
className="z-1 object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
@@ -63,31 +56,23 @@ export default function CartProduct({
|
||||
</div>
|
||||
|
||||
{/* ── Name + description ── */}
|
||||
<div className="flex-1 flex flex-col gap-1 px-3 pt-2.5 pb-1.5">
|
||||
<h3 className="font-bold text-sm leading-tight text-foreground line-clamp-1">
|
||||
<div className="flex flex-1 flex-col gap-1 px-3 pt-2.5 pb-1.5">
|
||||
<h3 className="text-foreground line-clamp-1 text-sm leading-tight font-bold">
|
||||
{productName}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-(--color-text-muted) line-clamp-2">
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-(--color-text-muted)">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Price + Buy button ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2.5
|
||||
border-t border-(--color-border-light) shrink-0"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--color-border-light) px-3 py-2.5">
|
||||
<span className="text-sm font-bold text-(--color-primary)">
|
||||
{formattedPrice}
|
||||
</span>
|
||||
<button
|
||||
onClick={onBuy}
|
||||
className="flex items-center gap-1.5 text-xs font-semibold
|
||||
px-3 py-1.5 rounded-lg border-none cursor-pointer
|
||||
bg-(--color-primary) text-white
|
||||
hover:bg-(--color-primary-dark)
|
||||
active:scale-95
|
||||
transition-all duration-150 whitespace-nowrap"
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-(--color-primary) px-3 py-1.5 text-xs font-semibold whitespace-nowrap text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-cart-plus"></i>
|
||||
Mua
|
||||
|
||||
+16
-56
@@ -31,36 +31,21 @@ export default function Navbar({
|
||||
}: NavbarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
sticky shrink-0 flex-col z-20
|
||||
border-r border-(--color-border)
|
||||
bg-(--color-bg-sidebar)
|
||||
overflow-y-auto overflow-x-hidden
|
||||
transition-all duration-250 ease-in-out
|
||||
hidden md:flex
|
||||
xl:w-60
|
||||
${isOpen ? "w-60" : "w-16"}
|
||||
`}
|
||||
style={{
|
||||
className={`sticky z-20 hidden shrink-0 flex-col overflow-x-hidden overflow-y-auto border-r border-(--color-border) bg-(--color-bg-sidebar) transition-all duration-250 ease-in-out md:flex xl:w-60 ${isOpen ? "w-60" : "w-16"} `}
|
||||
style={
|
||||
{
|
||||
top: "var(--spacing-header-height)",
|
||||
height: "calc(100vh - var(--spacing-header-height))",
|
||||
} as React.CSSProperties}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* ── Sidebar header: title + toggle button ── */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center border-b border-(--color-border) shrink-0
|
||||
xl:justify-between xl:px-4 xl:py-3
|
||||
${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"}
|
||||
`}
|
||||
className={`flex shrink-0 items-center border-b border-(--color-border) xl:justify-between xl:px-4 xl:py-3 ${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"} `}
|
||||
>
|
||||
{/* Title — shown when expanded, always shown on xl+ */}
|
||||
<span
|
||||
className={`
|
||||
text-xs font-bold uppercase tracking-widest
|
||||
text-(--color-text-muted) whitespace-nowrap
|
||||
${isOpen ? "block" : "hidden"} xl:block
|
||||
`}
|
||||
className={`text-xs font-bold tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${isOpen ? "block" : "hidden"} xl:block`}
|
||||
>
|
||||
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
|
||||
Thực Đơn
|
||||
@@ -70,10 +55,7 @@ export default function Navbar({
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg cursor-pointer border-none
|
||||
text-(--color-text-muted) bg-transparent
|
||||
hover:bg-(--color-border-light) hover:text-(--color-primary)
|
||||
transition-colors duration-150 shrink-0 xl:hidden"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid text-sm transition-transform duration-250 ${
|
||||
@@ -93,36 +75,23 @@ export default function Navbar({
|
||||
<button
|
||||
onClick={() => onCategoryChange?.(cat.id)}
|
||||
title={!isOpen ? cat.name : undefined}
|
||||
className={`
|
||||
w-full flex items-center rounded-xl text-sm font-medium
|
||||
cursor-pointer border-none transition-all duration-150
|
||||
xl:gap-3 xl:px-3 xl:py-2.5 xl:justify-start
|
||||
${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"}
|
||||
${
|
||||
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
|
||||
isActive
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
{/* Icon */}
|
||||
<i
|
||||
className={`
|
||||
${cat.icon} w-5 text-center text-base shrink-0
|
||||
${isActive ? "text-white" : "text-(--color-primary)"}
|
||||
`}
|
||||
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
|
||||
{/* Label — hidden when collapsed, always shown on xl+ */}
|
||||
<span
|
||||
className={`
|
||||
whitespace-nowrap overflow-hidden text-ellipsis
|
||||
${isOpen ? "block" : "hidden"} xl:block
|
||||
`}
|
||||
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -132,29 +101,20 @@ export default function Navbar({
|
||||
|
||||
{/* ── Sidebar footer: opening hours ── */}
|
||||
<div
|
||||
className={`
|
||||
shrink-0 border-t border-(--color-border) py-3
|
||||
xl:px-4
|
||||
${isOpen ? "px-4" : "px-0 flex justify-center"}
|
||||
`}
|
||||
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
|
||||
>
|
||||
{/* Text row — shown when expanded, always shown on xl+ */}
|
||||
<div
|
||||
className={`
|
||||
items-center gap-2 text-xs text-(--color-text-muted)
|
||||
${isOpen ? "flex" : "hidden"} xl:flex
|
||||
`}
|
||||
className={`items-center gap-2 text-xs text-(--color-text-muted) ${isOpen ? "flex" : "hidden"} xl:flex`}
|
||||
>
|
||||
<i className="fa-solid fa-clock text-(--color-accent) shrink-0"></i>
|
||||
<i className="fa-solid fa-clock shrink-0 text-(--color-accent)"></i>
|
||||
<span>{SHOP_INFO.openHours}</span>
|
||||
</div>
|
||||
|
||||
{/* Icon-only — shown when collapsed, hidden on xl+ */}
|
||||
<span className="xl:hidden">
|
||||
<i
|
||||
className={`
|
||||
fa-solid fa-clock text-sm text-(--color-text-muted)
|
||||
${isOpen ? "hidden" : "block"}`}
|
||||
className={`fa-solid fa-clock text-sm text-(--color-text-muted) ${isOpen ? "hidden" : "block"}`}
|
||||
title={SHOP_INFO.openHours}
|
||||
></i>
|
||||
</span>
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# --- Giai đoạn 1: Build ---
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
# Cài đặt pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy file định nghĩa package
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Cài đặt dependencies (sử dụng --frozen-lockfile để đảm bảo đúng phiên bản)
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy toàn bộ code
|
||||
COPY . .
|
||||
|
||||
# Build Next.js (Yêu cầu next.config.js có output: 'export')
|
||||
RUN pnpm run build
|
||||
|
||||
# --- Giai đoạn 2: Run (Sản phẩm cuối) ---
|
||||
FROM nginx:alpine
|
||||
|
||||
# Xóa file mặc định của nginx (tùy chọn nhưng nên làm)
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy folder out từ giai đoạn builder
|
||||
COPY --from=builder /app/out /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: drinkool-frontend
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: frontend-container
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:latest
|
||||
resources:
|
||||
limits:
|
||||
cpu: "50m"
|
||||
memory: "32Mi"
|
||||
requests:
|
||||
cpu: "10m"
|
||||
memory: "16Mi"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: my-web-service
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30080
|
||||
+40
-65
@@ -1,6 +1,6 @@
|
||||
import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Site Footer — 3-section 12-column grid.
|
||||
@@ -17,16 +17,15 @@ import { SHOP_INFO, SOCIAL_LINKS } from "@/lib/constants";
|
||||
*/
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full bg-(--color-bg-footer) text-(--color-text-on-dark) overflow-x-hidden">
|
||||
<footer className="w-full overflow-x-hidden bg-(--color-bg-footer) text-(--color-text-on-dark)">
|
||||
{/* ── Main grid ── */}
|
||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8 py-10">
|
||||
<div className="mx-auto max-w-screen-2xl px-4 py-10 md:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-12 gap-x-8 gap-y-8">
|
||||
|
||||
{/* ── 1. Brand info ── */}
|
||||
<div className="col-span-12 md:col-span-6 lg:col-span-8 xl:col-span-6">
|
||||
{/* Logo + name */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="relative w-10 h-10 shrink-0">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={`Logo ${SHOP_INFO.name}`}
|
||||
@@ -35,42 +34,42 @@ export default function Footer() {
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-(--color-accent)">
|
||||
<span className="text-lg font-bold text-(--color-accent)">
|
||||
{SHOP_INFO.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="text-sm opacity-75 leading-relaxed mb-4">
|
||||
<p className="mb-4 text-sm leading-relaxed opacity-75">
|
||||
{SHOP_INFO.tagline}
|
||||
</p>
|
||||
|
||||
{/* Contact details */}
|
||||
<ul className="flex flex-col gap-2 text-sm opacity-80">
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="fa-solid fa-location-dot mt-0.5 w-4 text-center text-(--color-accent) shrink-0"></i>
|
||||
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<span>Địa chỉ: {SHOP_INFO.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-phone w-4 text-center text-(--color-accent) shrink-0"></i>
|
||||
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<a
|
||||
href={`tel:${SHOP_INFO.phone}`}
|
||||
className="hover:text-(--color-accent) transition-colors duration-150"
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
Số điện thoại: {SHOP_INFO.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-envelope w-4 text-center text-(--color-accent) shrink-0"></i>
|
||||
<i className="fa-solid fa-envelope w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<a
|
||||
href={`mailto:${SHOP_INFO.email}`}
|
||||
className="hover:text-(--color-accent) transition-colors duration-150"
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
Email: {SHOP_INFO.email}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-clock w-4 text-center text-(--color-accent) shrink-0"></i>
|
||||
<i className="fa-solid fa-clock w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<span>Open: {SHOP_INFO.openHours}</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -80,11 +79,10 @@ export default function Footer() {
|
||||
md : side-by-side (each half of the 6-col right block)
|
||||
lg : stacked (WiFi below Social, both full width of the 4-col block)
|
||||
── */}
|
||||
<div className="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-6 grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<div className="col-span-12 grid grid-cols-1 gap-6 md:col-span-6 lg:col-span-4 xl:col-span-6 xl:grid-cols-2">
|
||||
{/* ── 2. Social links ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-(--color-accent)">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
Kết nối
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
@@ -93,13 +91,9 @@ export default function Footer() {
|
||||
href={SOCIAL_LINKS.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
|
||||
hover:text-(--color-accent) transition-all duration-150"
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
|
||||
bg-[#1877F2] text-white text-base"
|
||||
className="flex items-center gap-3 text-sm opacity-75 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#1877F2] text-base text-white">
|
||||
<i className="fa-brands fa-facebook-f"></i>
|
||||
</span>
|
||||
Facebook
|
||||
@@ -110,13 +104,9 @@ export default function Footer() {
|
||||
href={SOCIAL_LINKS.tiktok}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
|
||||
hover:text-(--color-accent) transition-all duration-150"
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
|
||||
bg-black text-white text-base"
|
||||
className="flex items-center gap-3 text-sm opacity-75 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-black text-base text-white">
|
||||
<i className="fa-brands fa-tiktok"></i>
|
||||
</span>
|
||||
TikTok
|
||||
@@ -125,13 +115,9 @@ export default function Footer() {
|
||||
<li>
|
||||
<Link
|
||||
href={SOCIAL_LINKS.website}
|
||||
className="flex items-center gap-3 text-sm opacity-75 hover:opacity-100
|
||||
hover:text-(--color-accent) transition-all duration-150"
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0
|
||||
bg-(--color-primary) text-white text-base"
|
||||
className="flex items-center gap-3 text-sm opacity-75 transition-all duration-150 hover:text-(--color-accent) hover:opacity-100"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-(--color-primary) text-base text-white">
|
||||
<i className="fa-solid fa-globe"></i>
|
||||
</span>
|
||||
Website
|
||||
@@ -142,61 +128,50 @@ export default function Footer() {
|
||||
|
||||
{/* ── 3. WiFi card ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="font-bold text-sm uppercase tracking-wider mb-4 text-(--color-accent)">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
WiFi Miễn Phí
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-xl border border-(--color-primary-light) border-opacity-50
|
||||
p-4 bg-(--color-primary-dark) bg-opacity-30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<i className="fa-solid fa-wifi text-(--color-accent) text-lg shrink-0"></i>
|
||||
<span className="font-semibold text-sm">Kết nối miễn phí</span>
|
||||
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
|
||||
<span className="text-sm font-semibold">
|
||||
Kết nối miễn phí
|
||||
</span>
|
||||
</div>
|
||||
{/* Stacked label + value rows — no overflow risk */}
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="opacity-60 text-xs uppercase tracking-wide">Tên mạng</span>
|
||||
<span
|
||||
className="font-mono font-bold text-(--color-accent)
|
||||
px-2 py-1 rounded border border-(--color-accent)
|
||||
border-opacity-30 break-all"
|
||||
>
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Tên mạng
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="opacity-60 text-xs uppercase tracking-wide">Mật khẩu</span>
|
||||
<span
|
||||
className="font-mono font-bold text-(--color-accent) tracking-wider
|
||||
px-2 py-1 rounded border border-(--color-accent)
|
||||
border-opacity-30 break-all"
|
||||
>
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Mật khẩu
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.password}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom bar ── */}
|
||||
<div className="border-t border-white border-opacity-10">
|
||||
<div
|
||||
className="max-w-screen-2xl mx-auto px-4 md:px-6 lg:px-8 py-4
|
||||
flex flex-col sm:flex-row items-center justify-between gap-2
|
||||
text-xs opacity-50"
|
||||
>
|
||||
<div className="border-opacity-10 border-t border-white">
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs opacity-50 sm:flex-row md:px-6 lg:px-8">
|
||||
<span>
|
||||
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
Được vận hành {" "}
|
||||
<i className="fa-solid fa-heart text-(--color-accent) mx-1"></i>{" "}
|
||||
Được vận hành{" "}
|
||||
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
||||
bằng Drinkool
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+15
-61
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
/**
|
||||
* Site Header — sticky top bar, always visible on all screen sizes.
|
||||
@@ -36,25 +36,15 @@ export default function Header() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className="w-full sticky top-0 z-50
|
||||
bg-(--color-bg-header) border-b border-(--color-border)
|
||||
shadow-[0_1px_8px_var(--color-shadow-sm)]
|
||||
h-(--spacing-header-height)"
|
||||
>
|
||||
<div
|
||||
className="h-full px-6 md:px-8 lg:px-12
|
||||
flex items-center justify-between gap-6
|
||||
max-w-screen-2xl mx-auto"
|
||||
>
|
||||
|
||||
<header className="sticky top-0 z-50 h-(--spacing-header-height) w-full border-b border-(--color-border) bg-(--color-bg-header) shadow-[0_1px_8px_var(--color-shadow-sm)]">
|
||||
<div className="mx-auto flex h-full max-w-screen-2xl items-center justify-between gap-6 px-6 md:px-8 lg:px-12">
|
||||
{/* ── LEFT: Brand ── */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-4 shrink-0 no-underline group"
|
||||
className="group flex shrink-0 items-center gap-4 no-underline"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="relative w-10 h-10 md:w-11 md:h-11 shrink-0">
|
||||
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={`Logo ${SHOP_INFO.name}`}
|
||||
@@ -67,100 +57,64 @@ export default function Header() {
|
||||
|
||||
{/* Name + tagline */}
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span
|
||||
className="font-bold text-base md:text-lg
|
||||
text-(--color-primary-dark)
|
||||
group-hover:text-(--color-primary)
|
||||
transition-colors duration-150"
|
||||
>
|
||||
<span className="text-base font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-lg">
|
||||
{SHOP_INFO.name}
|
||||
</span>
|
||||
<span className="text-xs hidden md:block text-(--color-text-muted)">
|
||||
<span className="hidden text-xs text-(--color-text-muted) md:block">
|
||||
{SHOP_INFO.tagline}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* ── RIGHT: Auth ── */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!user ? (
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng nhập"
|
||||
className="flex items-center gap-2.5 px-5 py-2.5 rounded-xl
|
||||
text-sm font-semibold border-none cursor-pointer
|
||||
bg-(--color-primary) text-white
|
||||
hover:bg-(--color-primary-dark)
|
||||
active:scale-95 transition-all duration-150"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-right-to-bracket"></i>
|
||||
<span className="hidden sm:inline">Đăng nhập</span>
|
||||
</button>
|
||||
|
||||
) : user.role === "manager" ? (
|
||||
/* Manager: gold badge */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 rounded-xl
|
||||
text-sm font-semibold cursor-pointer
|
||||
bg-(--color-accent-light) border border-(--color-accent)
|
||||
text-(--color-primary-dark)
|
||||
hover:bg-(--color-accent) hover:text-white
|
||||
active:scale-95 transition-all duration-150"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) transition-all duration-150 hover:bg-(--color-accent) hover:text-white active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-user-tie text-base"></i>
|
||||
<span className="hidden sm:inline">Quản lý</span>
|
||||
</button>
|
||||
|
||||
) : user.role === "staff" ? (
|
||||
/* Staff: avatar + name */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
className="flex items-center gap-2.5 px-4 py-2 rounded-xl
|
||||
text-sm font-semibold cursor-pointer
|
||||
bg-background border border-(--color-border)
|
||||
text-(--color-text-secondary)
|
||||
hover:border-(--color-primary-light)
|
||||
hover:bg-(--color-border-light)
|
||||
active:scale-95 transition-all duration-150"
|
||||
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0
|
||||
bg-(--color-primary-light) text-white text-xs"
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-primary-light) text-xs text-white">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">{user.name}</span>
|
||||
</button>
|
||||
|
||||
) : (
|
||||
/* Customer: phone icon + label */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
|
||||
className="flex items-center gap-2.5 px-4 py-2 rounded-xl
|
||||
text-sm font-semibold cursor-pointer
|
||||
bg-(--color-primary-light) text-white
|
||||
hover:bg-(--color-primary)
|
||||
active:scale-95 transition-all duration-150
|
||||
border-none"
|
||||
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="w-7 h-7 rounded-full flex items-center justify-center shrink-0
|
||||
bg-white text-(--color-primary-light) text-xs"
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">Khách hàng</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+84
-8
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -17,16 +24,76 @@ 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" } },
|
||||
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" } },
|
||||
"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" } },
|
||||
"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 }) {
|
||||
@@ -85,7 +152,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, registerPhone, setRegisterPhone, completeRegistration }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
+14
-10
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { Product } from "@/lib/types";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
@@ -70,8 +70,8 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const increaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
|
||||
)
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -81,9 +81,9 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
||||
: item
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,25 +92,29 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity) ? Math.max(0, Math.floor(quantity)) : 0;
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
: 0;
|
||||
if (safeQty === 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: safeQty } : item))
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const totalItems = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items]
|
||||
[items],
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
[items]
|
||||
[items],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
@@ -124,7 +128,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
}),
|
||||
[items, totalItems, totalPrice]
|
||||
[items, totalItems, totalPrice],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
|
||||
+41
-19
@@ -32,7 +32,11 @@ export const MENU_CATEGORIES: MenuCategory[] = [
|
||||
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
|
||||
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
|
||||
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
|
||||
{ id: "giai-khat", name: "Giải Khát / Ăn Vặt", icon: "fa-solid fa-ice-cream" },
|
||||
{
|
||||
id: "giai-khat",
|
||||
name: "Giải Khát / Ăn Vặt",
|
||||
icon: "fa-solid fa-ice-cream",
|
||||
},
|
||||
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
|
||||
];
|
||||
|
||||
@@ -45,7 +49,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "cafe",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
|
||||
description:
|
||||
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -54,7 +59,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "cafe",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
|
||||
description:
|
||||
"Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -63,7 +69,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "cafe",
|
||||
price: 32000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
|
||||
description:
|
||||
"Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -72,7 +79,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "cafe",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
|
||||
description:
|
||||
"Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -81,7 +89,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "tra",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
|
||||
description:
|
||||
"Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -90,7 +99,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "tra",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
|
||||
description:
|
||||
"Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -99,7 +109,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "tra",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
|
||||
description:
|
||||
"Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -108,7 +119,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "sua-chua",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
|
||||
description:
|
||||
"Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -117,7 +129,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "sua-chua",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
|
||||
description:
|
||||
"Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -126,7 +139,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "nuoc-ep",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
|
||||
description:
|
||||
"Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -135,7 +149,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "nuoc-ep",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
|
||||
description:
|
||||
"Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -144,7 +159,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
|
||||
description:
|
||||
"Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -153,7 +169,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
|
||||
description:
|
||||
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -162,7 +179,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "giai-khat",
|
||||
price: 20000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
|
||||
description:
|
||||
"Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -171,7 +189,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "giai-khat",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
|
||||
description:
|
||||
"Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -180,7 +199,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
description:
|
||||
"Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -189,7 +209,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
|
||||
description:
|
||||
"Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
@@ -198,7 +219,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description: "Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
description:
|
||||
"Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.3",
|
||||
"dependencies": {
|
||||
"next": "16.1.7",
|
||||
"php": "^1.1.0",
|
||||
|
||||
+23
-11
@@ -1,28 +1,40 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"format": "prettier --write \"./**/*.{js,jsx,ts,tsx,json,md}\"",
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.7",
|
||||
"php": "^1.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^2.3.1"
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/react": "^19.2.14",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-next": "^0.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||
"@semantic-release/exec": "^7.1.0",
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4057
-261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import type { GlobalConfig } from "semantic-release";
|
||||
|
||||
const config: GlobalConfig = {
|
||||
tagFormat: "v${version}",
|
||||
repositoryUrl: "https://git.demonkernel.io.vn/FoodSurf/frontend.git",
|
||||
branches: [
|
||||
"main",
|
||||
{
|
||||
name: "dev-*",
|
||||
prerelease: "${name.replace('dev-', '')}",
|
||||
channel: "${name.replace('dev-', '')}",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
["@semantic-release/npm", { npmPublish: false }],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
prepareCmd:
|
||||
"pnpm build && node scripts/release.ts ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@saithodev/semantic-release-gitea",
|
||||
{
|
||||
giteaUrl: "https://git.demonkernel.io.vn/",
|
||||
assets: [
|
||||
{
|
||||
path: "release.zip",
|
||||
name: "Drinkool v${nextRelease.version}.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const version = process.argv[2];
|
||||
if (!version) {
|
||||
console.error("❌ Version không được cung cấp!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const zipPath = "release.zip";
|
||||
|
||||
console.log(`📦 Đang nén file cho version: ${version}...`);
|
||||
|
||||
execSync(`cd out && zip -r ../${zipPath} .`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
console.log(`✅ Đã tạo file zip: ${zipPath}`);
|
||||
|
||||
execSync("pnpm format");
|
||||
+1
-2
@@ -29,8 +29,7 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts",
|
||||
"types/**/*.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "script", "*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user