resolve conflict

This commit is contained in:
Thanh Quy - wolf
2026-03-29 22:29:44 +07:00
30 changed files with 4926 additions and 880 deletions
+5
View File
@@ -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": [],
+137
View File
@@ -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 }}
+2
View File
@@ -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
View File
@@ -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/"]
}
+9 -2
View File
@@ -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 |
+27 -50
View File
@@ -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
View File
@@ -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 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 : <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 :{" "}
<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
View File
@@ -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
View File
@@ -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"> 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>
+51 -69
View File
@@ -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>
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)"
>
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
</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 OTP (60s)
</button>
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+9 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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,
+42
View File
@@ -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
+39 -64
View File
@@ -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>{" "}
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
bằng Drinkool
</span>
</div>
+15 -61
View File
@@ -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 </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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
];
+2 -2
View File
@@ -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
View File
@@ -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"
}
}
+4057 -261
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -29,8 +29,7 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts",
"types/**/*.d.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "script", "*.ts"]
}