From 6df96e811b90b6addcb5c02bee6c986f23e69b8b Mon Sep 17 00:00:00 2001 From: Blackbox Agent Date: Tue, 24 Mar 2026 10:43:01 +0000 Subject: [PATCH] feat(auth): add Login and Register pages with validation and OTP --- .blackbox/project-context.md | 52 +++++++ app/login/page.tsx | 213 ++++++++++++++++++++++++++ app/providers.tsx | 9 +- app/register/page.tsx | 285 +++++++++++++++++++++++++++++++++++ layouts/header.tsx | 62 +++++--- lib/auth-context.tsx | 100 ++++++++++++ lib/types.ts | 1 + package-lock.json | 11 -- 8 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 .blackbox/project-context.md create mode 100644 app/login/page.tsx create mode 100644 app/register/page.tsx create mode 100644 lib/auth-context.tsx diff --git a/.blackbox/project-context.md b/.blackbox/project-context.md new file mode 100644 index 0000000..32f50ae --- /dev/null +++ b/.blackbox/project-context.md @@ -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 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" }