diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..59ca91b --- /dev/null +++ b/app/login/page.tsx @@ -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 ( +
+ {/* Login Form Card */} +
+ + {/* Logo & Shop Name */} +
+
+ {SHOP_INFO.name} +
+

+ {SHOP_INFO.name} +

+

+ Đăng nhập vào hệ thống +

+
+ + {/* Error Message */} + {errors.general && ( +
+ + {errors.general} +
+ )} + + {/* Login Form */} +
+ + {/* Username Input */} +
+ +
+ + { + 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)"} + `} + /> +
+ {errors.username && ( +

+ + {errors.username} +

+ )} +
+ + {/* Password Input */} +
+ +
+ + { + 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)"} + `} + /> + +
+ {errors.password && ( +

+ + {errors.password} +

+ )} +
+ + {/* Buttons */} +
+ {/* Login Button */} + + + {/* Register Button */} + + Đăng ký tài khoản + +
+
+ + {/* Demo Credentials Info */} +
+

Tài khoản demo:

+
    +
  • • Quản lý: admin / admin
  • +
  • • Nhân viên: Nguyễn Văn An / Nguyễn Văn An
  • +
  • • Khách hàng: 0987654321 / user1
  • +
+
+
+
+ ); +} diff --git a/app/providers.tsx b/app/providers.tsx index 53443d4..92eb966 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -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 ( - - {children} - + + + {children} + + ); } diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..571aa19 --- /dev/null +++ b/app/register/page.tsx @@ -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 ( +
+ {/* Register Form Card */} +
+ + {/* Logo & Shop Name */} +
+
+ {SHOP_INFO.name} +
+

+ {SHOP_INFO.name} +

+

+ {step === "phone" ? "Đăng ký tài khoản khách hàng" : "Xác thực số điện thoại"} +

+
+ + {/* Step Indicator */} +
+
+ 1 +
+
+
+ 2 +
+
+ + {/* Phone Step */} + {step === "phone" && ( +
+ + {/* Phone Input */} +
+ +
+ + { + 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)"} + `} + /> +
+ {errors.phone && ( +

+ + {errors.phone} +

+ )} +

+ Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0) +

+
+ + {/* Buttons */} +
+ {/* Submit Button */} + + + {/* Back to Login */} + + Quay lại đăng nhập + +
+
+ )} + + {/* OTP Step */} + {step === "otp" && ( +
+ + {/* Info Message */} +
+

+ + Mã OTP đã được gửi đến số {phone} +

+

+ Demo OTP: {DEMO_OTP} +

+
+ + {/* OTP Input */} +
+ +
+ + { + 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)"} + `} + /> +
+ {errors.otp && ( +

+ + {errors.otp} +

+ )} +
+ + {/* Buttons */} +
+ {/* Submit Button */} + + + {/* Back Button */} + +
+ + {/* Resend OTP (disabled in demo) */} +
+ +
+
+ )} +
+
+ ); +} diff --git a/layouts/header.tsx b/layouts/header.tsx index 9209a36..5b4ef0a 100644 --- a/layouts/header.tsx +++ b/layouts/header.tsx @@ -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(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 */ - ) : ( - /* Staff / regular user: avatar + name */ + ) : user.role === "staff" ? ( + /* Staff: avatar + name */ + + ) : ( + /* Customer: phone icon + label */ + )} diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx new file mode 100644 index 0000000..63908e3 --- /dev/null +++ b/lib/auth-context.tsx @@ -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(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(null); + const [registerPhone, setRegisterPhone] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/lib/types.ts b/lib/types.ts index 3d21df5..13b851b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -6,6 +6,7 @@ export interface User { name: string; role: UserRole; avatar: string | null; + phone?: string; } // ===== MENU TYPES ===== diff --git a/package-lock.json b/package-lock.json index d52a89f..87f6471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }