feat(auth): add Login and Register pages with validation and OTP
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Project Context
|
||||
|
||||
> This file is auto-generated by [Blackbox](https://www.blackbox.ai) to preserve project context across platforms.
|
||||
> It is regenerated on every commit. Do not edit manually.
|
||||
> Last updated: 2026-03-24T10:42:58.843Z
|
||||
|
||||
## Vision
|
||||
|
||||
Tạo cho tôi 2 page là Login và Register:
|
||||
Với trang Login:
|
||||
- Khi người dùng click và nút "Đăng nhập" ở trang home sẽ nhảy đến trang Login.
|
||||
- Trang login có background giống với background ở trang home.
|
||||
- Một form login nền trắng ở giữa viewport với logo và tên quán "Coffee Shop".
|
||||
- 2 input là Username và password. Ở cỡ lg trở lên thì có icon, ở cỡ lg trở xuống thì ẩn text thay bằng icon
|
||||
- 2 button ở phía dưới là đăng nhập và đăng ký.
|
||||
- Có validate cho các input
|
||||
- Dữ liệu: dữ liệu cho quản lý là: ...
|
||||
|
||||
## Original Task
|
||||
|
||||
Tạo cho tôi 2 page là Login và Register:
|
||||
Với trang Login:
|
||||
- Khi người dùng click và nút "Đăng nhập" ở trang home sẽ nhảy đến trang Login.
|
||||
- Trang login có background giống với background ở trang home.
|
||||
- Một form login nền trắng ở giữa viewport với logo và tên quán "Coffee Shop".
|
||||
- 2 input là Username và password. Ở cỡ lg trở lên thì có icon, ở cỡ lg trở xuống thì ẩn text thay bằng icon
|
||||
- 2 button ở phía dưới là đăng nhập và đăng ký.
|
||||
- Có validate cho các input
|
||||
- Dữ liệu: dữ liệu cho quản lý là: Username = admin, password = admin. Còn dữ liệu cho nhân viên là Username và Password: họ tên nhân viên. Đối với khách hàng thì Username là số điện thoại còn password do khách hàng đặt (user1).
|
||||
- Phần dữ liệu bạn tự làm cho tôi Trang đăng ký:
|
||||
- UI gần giống với Login Page. Chỉ khác là đây là trang đăng ký nhanh bằng số điện thoại dành cho khách hàng.
|
||||
- Khi ấn button đăng ký sẽ nhảy qua trang này
|
||||
- Khách hàng sẽ nhập số điện thoại, validate số điện thoại này cho tôi theo số điện thoại ở Việt Nam
|
||||
- Sau khi ấn Đăng ký sẽ thay đổi thành nhập OTP để hoàn thành đăng ký. Hãy tạo OTP tĩnh trong dự án sẵn vì chưa cần call API. Sau khi đăng nhập hoặc đăng ký thành công với user là khách hàng thì home page sẽ hiện icon biểu thị đó là khách hàng cùng với text mô tả. Còn là quản lý hay nhân viên thì hiển thị như đã có
|
||||
|
||||
## Change History
|
||||
|
||||
| # | Date | Instruction | Status |
|
||||
|---|------|-------------|--------|
|
||||
| 0 | 2026-03-24 | Tạo cho tôi 2 page là Login và Register:
|
||||
Với trang Login:
|
||||
- Khi người dùng click và nút "Đăng nhập" ... | saving |
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Branch:** agent/to-cho-ti-2-page-l-login-v-register-vi-trang-login-29-m7
|
||||
- **Lines Added:** N/A
|
||||
- **Lines Removed:** N/A
|
||||
- **Files Changed:** N/A
|
||||
- **Total Interactions:** 1
|
||||
- **Last Agent:** blackbox
|
||||
- **Last Model:** blackboxai/anthropic/claude-sonnet-4.5
|
||||
@@ -0,0 +1,213 @@
|
||||
"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";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({ username: "", password: "", general: "" });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors = { username: "", password: "", general: "" };
|
||||
let isValid = true;
|
||||
|
||||
if (!username.trim()) {
|
||||
newErrors.username = "Vui lòng nhập tên đăng nhập";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
newErrors.password = "Vui lòng nhập mật khẩu";
|
||||
isValid = false;
|
||||
} else if (password.length < 4) {
|
||||
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
const success = login(username, password);
|
||||
|
||||
if (success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setErrors({ username: "", password: "", general: "Tên đăng nhập hoặc mật khẩu không đúng" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8"
|
||||
style={{
|
||||
background: "var(--color-bg-main)",
|
||||
}}
|
||||
>
|
||||
{/* Login Form Card */}
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg p-8">
|
||||
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-(--color-primary-dark) mb-1">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Đăng nhập vào hệ thống
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
{/* Username Input */}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
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>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
}}
|
||||
placeholder="admin / số điện thoại / tên nhân viên"
|
||||
className={`
|
||||
w-full px-4 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-(--color-text-primary)
|
||||
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)"}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
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>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
placeholder="Nhập mật khẩu"
|
||||
className={`
|
||||
w-full px-4 lg:pl-11 pr-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-(--color-text-primary)
|
||||
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)"}
|
||||
`}
|
||||
/>
|
||||
<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"
|
||||
aria-label={showPassword ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
|
||||
>
|
||||
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
Đăng ký tài khoản
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Demo Credentials Info */}
|
||||
<div className="mt-6 p-4 bg-(--color-bg-main) 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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import { MenuProvider } from "@/lib/menu-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
|
||||
/**
|
||||
* Client-side providers wrapper.
|
||||
@@ -10,8 +11,10 @@ import { CartProvider } from "@/lib/cart-context";
|
||||
*/
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</MenuProvider>
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</MenuProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"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";
|
||||
|
||||
// Static OTP for demo (in production, this would be sent via SMS)
|
||||
const DEMO_OTP = "123456";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { completeRegistration } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [errors, setErrors] = useState({ phone: "", otp: "" });
|
||||
|
||||
// Validate Vietnamese phone number
|
||||
const validatePhone = (phoneNumber: string): boolean => {
|
||||
// Vietnamese phone format: 10 digits starting with 0
|
||||
// Valid prefixes: 03, 05, 07, 08, 09
|
||||
const phoneRegex = /^(0[3|5|7|8|9])[0-9]{8}$/;
|
||||
return phoneRegex.test(phoneNumber);
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({ ...errors, phone: "Số điện thoại không hợp lệ (VD: 0987654321)" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to OTP step
|
||||
setStep("otp");
|
||||
setErrors({ phone: "", otp: "" });
|
||||
};
|
||||
|
||||
const handleOtpSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (otp !== DEMO_OTP) {
|
||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete registration
|
||||
completeRegistration(phone);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleBackToPhone = () => {
|
||||
setStep("phone");
|
||||
setOtp("");
|
||||
setErrors({ phone: "", otp: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8"
|
||||
style={{
|
||||
background: "var(--color-bg-main)",
|
||||
}}
|
||||
>
|
||||
{/* Register Form Card */}
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg p-8">
|
||||
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-(--color-primary-dark) mb-1">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone" ? "Đăng ký tài khoản khách hàng" : "Xác thực số điện thoại"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="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 ${
|
||||
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 ${
|
||||
step === "otp"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone Step */}
|
||||
{step === "phone" && (
|
||||
<form onSubmit={handlePhoneSubmit} className="space-y-5">
|
||||
|
||||
{/* Phone Input */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
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>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
className={`
|
||||
w-full px-4 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-(--color-text-primary)
|
||||
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)"}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.phone}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
>
|
||||
Tiếp tục
|
||||
</button>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label htmlFor="otp" className="block text-sm font-medium text-(--color-text-secondary) mb-2">
|
||||
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>
|
||||
<input
|
||||
id="otp"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ ...errors, otp: "" });
|
||||
}}
|
||||
placeholder="Nhập mã OTP 6 số"
|
||||
maxLength={6}
|
||||
className={`
|
||||
w-full px-4 lg:pl-11 py-3 rounded-xl border outline-none
|
||||
bg-white text-(--color-text-primary) text-center font-mono 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)"}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
<p className="mt-1.5 text-xs text-red-500 flex items-center gap-1">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors.otp}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
>
|
||||
Hoàn tất đăng ký
|
||||
</button>
|
||||
|
||||
{/* Back Button */}
|
||||
<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"
|
||||
>
|
||||
Thay đổi số điện thoại
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP (disabled in demo) */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="text-sm text-(--color-text-muted) cursor-not-allowed"
|
||||
>
|
||||
Gửi lại mã OTP (60s)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+45
-17
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SHOP_INFO, MOCK_USERS } from "@/lib/constants";
|
||||
import type { User } from "@/lib/types";
|
||||
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.
|
||||
@@ -12,8 +12,11 @@ import type { User } from "@/lib/types";
|
||||
* 2-column layout:
|
||||
* [LEFT: Brand (logo + name + tagline)] | [RIGHT: Auth button]
|
||||
*
|
||||
* Auth state cycles for UI demo (click the button to switch):
|
||||
* Guest → Manager (Quản lý) → Staff → Guest
|
||||
* Auth states:
|
||||
* - Guest: Shows "Đăng nhập" button → navigates to /login
|
||||
* - Manager: Shows "Quản lý" badge with logout on click
|
||||
* - Staff: Shows staff name with logout on click
|
||||
* - Customer: Shows "Khách hàng" with phone and logout on click
|
||||
*
|
||||
* Responsive:
|
||||
* - Logo + shop name : always visible
|
||||
@@ -21,12 +24,15 @@ import type { User } from "@/lib/types";
|
||||
* - Button label : hidden on xs (< sm), shown on sm+
|
||||
*/
|
||||
export default function Header() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const cycleUser = () => {
|
||||
if (!user) setUser(MOCK_USERS.manager);
|
||||
else if (user.role === "manager") setUser(MOCK_USERS.staff);
|
||||
else setUser(null);
|
||||
const handleAuthClick = () => {
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -81,7 +87,7 @@ export default function Header() {
|
||||
{!user ? (
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={cycleUser}
|
||||
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
|
||||
@@ -96,8 +102,8 @@ export default function Header() {
|
||||
) : user.role === "manager" ? (
|
||||
/* Manager: gold badge */
|
||||
<button
|
||||
onClick={cycleUser}
|
||||
title="Nhấn để đổi tài khoản (demo)"
|
||||
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)
|
||||
@@ -109,11 +115,11 @@ export default function Header() {
|
||||
<span className="hidden sm:inline">Quản lý</span>
|
||||
</button>
|
||||
|
||||
) : (
|
||||
/* Staff / regular user: avatar + name */
|
||||
) : user.role === "staff" ? (
|
||||
/* Staff: avatar + name */
|
||||
<button
|
||||
onClick={cycleUser}
|
||||
title="Nhấn để đổi tài khoản (demo)"
|
||||
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)
|
||||
@@ -131,6 +137,28 @@ export default function Header() {
|
||||
</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"
|
||||
>
|
||||
{/* 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"
|
||||
>
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">Khách hàng</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => boolean;
|
||||
logout: () => void;
|
||||
registerPhone: string | null;
|
||||
setRegisterPhone: (phone: string | null) => void;
|
||||
completeRegistration: (phone: string) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// Mock user database
|
||||
const MOCK_AUTH_DB = {
|
||||
// Admin
|
||||
admin: { username: "admin", password: "admin", user: { id: 1, name: "Quản lý", role: "manager" as const, avatar: null, phone: "0912345678" } },
|
||||
|
||||
// Staff (username and password are their names)
|
||||
"Nguyễn Văn An": { username: "Nguyễn Văn An", password: "Nguyễn Văn An", user: { id: 2, name: "Nguyễn Văn An", role: "staff" as const, avatar: null, phone: "0901234567" } },
|
||||
"Trần Thị Bình": { username: "Trần Thị Bình", password: "Trần Thị Bình", user: { id: 3, name: "Trần Thị Bình", role: "staff" as const, avatar: null, phone: "0902345678" } },
|
||||
"Lê Văn Cường": { username: "Lê Văn Cường", password: "Lê Văn Cường", user: { id: 4, name: "Lê Văn Cường", role: "staff" as const, avatar: null, phone: "0903456789" } },
|
||||
|
||||
// Customers (username is phone number, password is custom)
|
||||
"0987654321": { username: "0987654321", password: "user1", user: { id: 5, name: "Khách hàng", role: "customer" as const, avatar: null, phone: "0987654321" } },
|
||||
"0976543210": { username: "0976543210", password: "user1", user: { id: 6, name: "Khách hàng", role: "customer" as const, avatar: null, phone: "0976543210" } },
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (username: string, password: string): boolean => {
|
||||
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
|
||||
|
||||
if (authEntry && authEntry.password === password) {
|
||||
setUser(authEntry.user);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("coffee-shop-user");
|
||||
};
|
||||
|
||||
const completeRegistration = (phone: string) => {
|
||||
// Create new customer account
|
||||
const newUser: User = {
|
||||
id: Date.now(),
|
||||
name: "Khách hàng",
|
||||
role: "customer",
|
||||
avatar: null,
|
||||
phone,
|
||||
};
|
||||
|
||||
// Add to mock database (in real app, this would be API call)
|
||||
(MOCK_AUTH_DB as any)[phone] = {
|
||||
username: phone,
|
||||
password: "user1", // Default password for new customers
|
||||
user: newUser,
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
|
||||
setRegisterPhone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, registerPhone, setRegisterPhone, completeRegistration }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar: string | null;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
// ===== MENU TYPES =====
|
||||
|
||||
Generated
-11
@@ -68,7 +68,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1572,7 +1571,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1632,7 +1630,6 @@
|
||||
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
@@ -2171,7 +2168,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2666,7 +2662,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3475,7 +3470,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6523,7 +6517,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6533,7 +6526,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7478,7 +7470,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7659,7 +7650,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8019,7 +8009,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user