Quy-dev
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginOtpPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ otp: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || !storedRole) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
setRole(storedRole);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ otp: "Vui lòng nhập mã OTP", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ otp: "", general: "" });
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
role === "manager"
|
||||
? "/api/manager/quick_login"
|
||||
: "/api/customer/quick_login";
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push(role === "manager" ? "/manager" : "/");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const msg =
|
||||
errorCode === "InvalidOTP"
|
||||
? "Mã OTP không đúng hoặc đã hết hạn"
|
||||
: "Đã có lỗi xảy ra, vui lòng thử lại";
|
||||
setErrors({ otp: msg, general: "" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ otp: "", general: "Không thể kết nối, vui lòng thử lại" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<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}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Xác thực số điện thoại
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Info */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mã OTP
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="otp"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ otp: "", general: "" });
|
||||
}}
|
||||
placeholder="Nhập mã OTP"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
<ErrorMessageLogin message={errors.otp} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Đang xử lý...
|
||||
</>
|
||||
) : (
|
||||
"Đăng nhập"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/login")}
|
||||
className="flex w-full items-center justify-center 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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,39 +25,12 @@ export default function LoginPage() {
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Đăng nhập vào hệ thống
|
||||
Nhập số điện thoại để đăng nhập
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<LoginForm />
|
||||
|
||||
{/* Demo Credentials Info */}
|
||||
<div className="bg-background mt-6 rounded-lg p-4">
|
||||
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
|
||||
Tài khoản demo:
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-(--color-text-muted)">
|
||||
<li>
|
||||
• Quản lý:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
admin / admin
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Nhân viên:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
Nguyễn Văn An / Nguyễn Văn An
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Khách hàng:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
0987654321 / user1
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ password: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || storedRole !== "manager") {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password.trim()) {
|
||||
setErrors({ password: "Vui lòng nhập mật khẩu", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ password: "", general: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, password, role: "manager" }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push("/manager");
|
||||
} else {
|
||||
const STATUS_ERROR_MAP: Record<number, string> = {
|
||||
400: "Sai thông tin đăng nhập, vui lòng thử lại",
|
||||
401: "Mật khẩu không đúng",
|
||||
403: "Tài khoản bị khóa",
|
||||
404: "Tài khoản không tồn tại",
|
||||
};
|
||||
const msg =
|
||||
STATUS_ERROR_MAP[res.status] ??
|
||||
(res.status >= 500
|
||||
? "Lỗi máy chủ, vui lòng thử lại sau"
|
||||
: "Đã có lỗi xảy ra, vui lòng thử lại");
|
||||
setErrors({ password: "", general: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ password: "", general: "Không thể kết nối, vui lòng thử lại" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<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}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">Đăng nhập quản lý</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Phone display */}
|
||||
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
|
||||
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
|
||||
{phone}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<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 top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ password: "", general: "" });
|
||||
}}
|
||||
placeholder="Nhập mật khẩu"
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Đang xử lý...
|
||||
</>
|
||||
) : (
|
||||
"Đăng nhập"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/login")}
|
||||
className="flex w-full items-center justify-center 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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
type PageState = "checking" | "available" | "closed" | "error";
|
||||
|
||||
export default function ManagerSignupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>("checking");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
|
||||
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/manager/signup")
|
||||
.then((res) => {
|
||||
setPageState("available")
|
||||
// if (res.ok) ;
|
||||
// else if (res.status === 403) setPageState("closed");
|
||||
// else setPageState("error");
|
||||
})
|
||||
.catch(() => setPageState("error"));
|
||||
}, []);
|
||||
|
||||
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
|
||||
|
||||
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [field]: e.target.value });
|
||||
setErrors({ ...errors, [field]: "", submit: "" });
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
|
||||
if (!form.name.trim()) next.name = "Vui lòng nhập họ tên";
|
||||
if (!form.phone.trim()) next.phone = "Vui lòng nhập số điện thoại";
|
||||
else if (!validatePhone(form.phone)) next.phone = "Số điện thoại không hợp lệ (VD: 0987654321)";
|
||||
if (!form.password.trim()) next.password = "Vui lòng nhập mật khẩu";
|
||||
else if (form.password.length < 6) next.password = "Mật khẩu ít nhất 6 ký tự";
|
||||
if (!form.eateryName.trim()) next.eateryName = "Vui lòng nhập tên nhà hàng";
|
||||
setErrors(next);
|
||||
return !next.name && !next.phone && !next.password && !next.eateryName;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/manager/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok || res.status === 201) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const errorMap: Record<string, string> = {
|
||||
ExistedUser: "Số điện thoại đã được đăng ký",
|
||||
InvalidPhoneNumber: "Số điện thoại không hợp lệ",
|
||||
};
|
||||
setErrors({ ...errors, submit: errorMap[errorCode] ?? "Đã có lỗi xảy ra, vui lòng thử lại" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ ...errors, submit: "Không thể kết nối, vui lòng thử lại" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<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} fill className="object-contain" sizes="80px" priority />
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">{SHOP_INFO.name}</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">Đăng ký tài khoản quản lý</p>
|
||||
</div>
|
||||
|
||||
{/* Checking */}
|
||||
{pageState === "checking" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
|
||||
<p className="text-sm">Đang kiểm tra...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed */}
|
||||
{pageState === "closed" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Đăng ký đã đóng</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">Hệ thống đã có nhà hàng. Chức năng đăng ký không còn khả dụng.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
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"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{pageState === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100">
|
||||
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Không thể kết nối</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng ký. Vui lòng thử lại.</p>
|
||||
</div>
|
||||
<Button variant="primaryNoBorder" style="login" size="lg" onClick={() => { setPageState("checking"); fetch("/api/manager/signup").then((r) => { if (r.ok) setPageState("available"); else if (r.status === 403) setPageState("closed"); else setPageState("error"); }).catch(() => setPageState("error")); }}>
|
||||
Thử lại
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-(--color-primary) underline">Quay lại đăng nhập</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signup Form */}
|
||||
{pageState === "available" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
|
||||
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
|
||||
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
|
||||
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
|
||||
].map(({ id, label, icon, placeholder, field, type }) => (
|
||||
<div key={id}>
|
||||
<label htmlFor={id} className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}></i>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={form[field]}
|
||||
onChange={handleChange(field)}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[field] && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors[field]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.submit && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<i className="fa-solid fa-circle-exclamation mr-2"></i>
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử lý...</>
|
||||
) : (
|
||||
"Đăng ký"
|
||||
)}
|
||||
</Button>
|
||||
<Link
|
||||
href="/login"
|
||||
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"
|
||||
>
|
||||
Đã có tài khoản? Đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export default function RegisterPage() {
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customer/quickLogin", {
|
||||
const res = await fetch("/api/customer/quick_login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
MenuItemsTab,
|
||||
ProductsTab,
|
||||
} from "@/components/organisms/manager";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
@@ -33,6 +34,12 @@ export default function ManagerPage() {
|
||||
icon: "fa-solid fa-tags",
|
||||
count: categories.length,
|
||||
},
|
||||
{
|
||||
id: "menu-items" as const,
|
||||
label: "Menu",
|
||||
icon: "fa-solid fa-bowl-food",
|
||||
count: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -65,6 +72,7 @@ export default function ManagerPage() {
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1 text-left">{tab.label}</span>
|
||||
{tab.count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
activeTab === tab.id
|
||||
@@ -74,6 +82,7 @@ export default function ManagerPage() {
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
@@ -147,7 +156,9 @@ export default function ManagerPage() {
|
||||
? "thực đơn"
|
||||
: activeTab === "combos"
|
||||
? "combo"
|
||||
: "danh mục"}{" "}
|
||||
: activeTab === "categories"
|
||||
? "danh mục"
|
||||
: "menu"}{" "}
|
||||
của quán
|
||||
</p>
|
||||
</div>
|
||||
@@ -200,6 +211,7 @@ export default function ManagerPage() {
|
||||
{activeTab === "products" && <ProductsTab />}
|
||||
{activeTab === "combos" && <CombosTab />}
|
||||
{activeTab === "categories" && <CategoriesTab />}
|
||||
{activeTab === "menu-items" && <MenuItemsTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { BadgeProps } from "./Badge.types";
|
||||
|
||||
export default function Badge({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
primary: "bg-(--color-primary) text-white",
|
||||
secondary: "bg-(--color-border-light) text-(--color-text-secondary)",
|
||||
success: "bg-green-100 text-green-700",
|
||||
danger: "bg-red-100 text-red-700",
|
||||
warning: "bg-yellow-100 text-yellow-700",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-semibold ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
|
||||
size?: "sm" | "md";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface PriceBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
price: number;
|
||||
currency?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PriceBadge({
|
||||
price,
|
||||
currency = "VND",
|
||||
className = "",
|
||||
...props
|
||||
}: PriceBadgeProps) {
|
||||
const formattedPrice =
|
||||
currency === "VND"
|
||||
? price.toLocaleString("vi-VN", {
|
||||
style: "currency",
|
||||
currency: "VND",
|
||||
})
|
||||
: `${price.toFixed(2)} ${currency}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-bold text-(--color-primary) ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{formattedPrice}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as PriceBadge } from "./PriceBadge";
|
||||
export type { BadgeProps } from "./Badge.types";
|
||||
export type { PriceBadgeProps } from "./PriceBadge";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export default function IconButton({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon,
|
||||
disabled = false,
|
||||
className = "",
|
||||
children,
|
||||
style: _style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
secondary:
|
||||
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||
primaryNoBorder:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
bgWhite:
|
||||
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 w-8 text-sm",
|
||||
md: "h-10 w-10 text-base",
|
||||
lg: "h-12 w-12 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,13 @@ export { Button } from "./buttons";
|
||||
export type { ButtonProps } from "./buttons";
|
||||
|
||||
// Inputs
|
||||
export { TextInput, SearchInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
|
||||
export { TextInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, TextareaProps } from "./inputs";
|
||||
|
||||
// Typography
|
||||
export { Heading, Text, Caption } from "./typography";
|
||||
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
|
||||
|
||||
// Badges
|
||||
export { Badge, PriceBadge } from "./badges";
|
||||
export type { BadgeProps } from "./badges";
|
||||
|
||||
// Dividers
|
||||
export { Divider } from "./dividers";
|
||||
export type { DividerProps } from "./dividers";
|
||||
|
||||
@@ -13,17 +13,3 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
errors?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LoginInputProps } from "./Input.types";
|
||||
|
||||
export default function LoginInput({
|
||||
label,
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
errors,
|
||||
onChange,
|
||||
...restProps
|
||||
}: LoginInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
function isPassword() {
|
||||
if (type === "password") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<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={name}
|
||||
type={showPassword ? "text" : type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={
|
||||
type === "password"
|
||||
? "Mật khẩu"
|
||||
: "admin / số điện thoại / tên nhân viên"
|
||||
}
|
||||
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 ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
{isPassword()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { SearchInputProps } from "./Input.types";
|
||||
|
||||
export default function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
className = "",
|
||||
...props
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<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={value}
|
||||
onChange={onChange}
|
||||
className={`w-full rounded-lg border border-(--color-border) bg-transparent py-2 pr-9 pl-9 text-sm transition-all duration-150 placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{value && onClear && (
|
||||
<button
|
||||
title="Xóa"
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
aria-label="Xóa"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
export { default as TextInput } from "./TextInput";
|
||||
export { default as SearchInput } from "./SearchInput";
|
||||
export { default as Textarea } from "./Textarea";
|
||||
export type {
|
||||
TextInputProps,
|
||||
SearchInputProps,
|
||||
TextareaProps,
|
||||
LoginInputProps,
|
||||
} from "./Input.types";
|
||||
export type { TextInputProps, TextareaProps } from "./Input.types";
|
||||
|
||||
@@ -1,81 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitEvent, useState } from "react";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
const LOGIN_ERROR_MAP: Record<string, { field: "username" | "password" | "general"; msg: string }> = {
|
||||
InvalidPhoneNumber: { field: "username", msg: "Số điện thoại không hợp lệ" },
|
||||
InvalidPassword: { field: "password", msg: "Mật khẩu không đúng" },
|
||||
};
|
||||
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
const [errors, setErrors] = useState({ phone: "", general: "" });
|
||||
|
||||
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 (!phone.trim()) {
|
||||
setErrors({ phone: "Vui lòng nhập số điện thoại", general: "" });
|
||||
return 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;
|
||||
if (!PHONE_REGEX.test(phone)) {
|
||||
setErrors({ phone: "Số điện thoại không hợp lệ (VD: 0987654321)", general: "" });
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ username: "", password: "", general: "" });
|
||||
setErrors({ phone: "", general: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customer/login", {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: username, password }),
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
router.push("/");
|
||||
const role = (await res.text().catch(() => "")).trim();
|
||||
|
||||
if (res.ok && (role === "customer" || role === "manager")) {
|
||||
sessionStorage.setItem("login_phone", phone);
|
||||
sessionStorage.setItem("login_role", role);
|
||||
router.push(role === "manager" ? "/login/password" : "/login/otp");
|
||||
} else if (res.status === 404) {
|
||||
setErrors({ phone: "", general: "Số điện thoại chưa đăng ký" });
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const mapped = LOGIN_ERROR_MAP[errorCode];
|
||||
if (mapped) {
|
||||
setErrors({ username: "", password: "", general: "", [mapped.field]: mapped.msg });
|
||||
} else {
|
||||
setErrors({ username: "", password: "", general: "Đã có lỗi xảy ra, vui lòng thử lại" });
|
||||
}
|
||||
setErrors({ phone: "", general: "Đã có lỗi xảy ra, vui lòng thử lại" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ username: "", password: "", general: "Không thể kết nối, vui lòng thử lại" });
|
||||
setErrors({ phone: "", general: "Không thể kết nối, vui lòng thử lại" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -83,49 +61,40 @@ export default function LoginForm() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Message */}
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
{/* Username Input */}
|
||||
<LoginInput
|
||||
label="Tên đăng nhập"
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
<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 top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
setPhone(e.target.value);
|
||||
setErrors({ phone: "", general: "" });
|
||||
}}
|
||||
errors={errors.username}
|
||||
placeholder="0987654321"
|
||||
disabled={isLoading}
|
||||
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) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
{errors.username && (
|
||||
<ErrorMessageLogin message={errors.username} type="secondary" />
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<ErrorMessageLogin message={errors.phone} type="secondary" />
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<LoginInput
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
errors={errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
@@ -139,11 +108,10 @@ export default function LoginForm() {
|
||||
Đang xử lý...
|
||||
</>
|
||||
) : (
|
||||
"Đăng nhập"
|
||||
"Tiếp tục"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Register Button */}
|
||||
<Link
|
||||
href="/register"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { gqlFetch } from "@/lib/graphql";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
export default function MenuItemsTab() {
|
||||
const { user } = useAuth();
|
||||
const [eateryId, setEateryId] = useState<string | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPrice, setNewPrice] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadEateryId = useCallback(async (): Promise<string | null> => {
|
||||
if (!user) return null;
|
||||
const data = await gqlFetch<{ allEateries: Eatery[] }>(
|
||||
"{ allEateries { id ownerId } }",
|
||||
);
|
||||
return (
|
||||
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
|
||||
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||
`query($eateryId: String!) {
|
||||
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||
}`,
|
||||
{ eateryId: id },
|
||||
);
|
||||
return data.menuItemsByEatery ?? [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = await loadEateryId();
|
||||
setEateryId(id);
|
||||
if (id) setMenuItems(await loadMenuItems(id));
|
||||
else setMenuItems([]);
|
||||
} catch {
|
||||
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [loadEateryId, loadMenuItems]);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() || !newPrice) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const data = await gqlFetch<{ addMenuItem: MenuItem }>(
|
||||
`mutation($name: String!, $price: Float!) {
|
||||
addMenuItem(name: $name, price: $price) { id name price }
|
||||
}`,
|
||||
{ name: newName.trim(), price: parseFloat(newPrice) },
|
||||
);
|
||||
setMenuItems((prev) => [...prev, data.addMenuItem]);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
setShowForm(false);
|
||||
} catch {
|
||||
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setSubmitError(null);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
|
||||
Đang tải menu...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eateryId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
|
||||
<p className="text-sm">Quán của bạn chưa được khởi tạo trong hệ thống.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{menuItems.length}</strong> món trong menu
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Thêm món</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-(--color-border-light)">
|
||||
{menuItems.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="py-12 text-center text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
|
||||
Chưa có món nào trong menu
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
menuItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-foreground font-medium">{item.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add Item Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-foreground font-bold">Thêm món mới</h2>
|
||||
<button
|
||||
onClick={closeForm}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên món <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Nhập tên món..."
|
||||
required
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (VNĐ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="Nhập giá..."
|
||||
required
|
||||
min={0}
|
||||
step={500}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p className="text-sm text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1"></i>
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForm}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
"Thêm món"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export { default as ComboModal } from "./ComboModal";
|
||||
export { default as ProductsTab } from "./ProductsTab";
|
||||
export { default as CategoriesTab } from "./CategoriesTab";
|
||||
export { default as CombosTab } from "./CombosTab";
|
||||
export { default as MenuItemsTab } from "./MenuItemsTab";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
|
||||
/**
|
||||
* Auth layout template — centers content in the screen.
|
||||
* Used by login and register pages.
|
||||
*/
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
export type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
@@ -2,10 +2,6 @@
|
||||
export { MainLayout } from "./main-layout";
|
||||
export type { MainLayoutProps } from "./main-layout";
|
||||
|
||||
// Auth Layout
|
||||
export { AuthLayout } from "./auth-layout";
|
||||
export type { AuthLayoutProps } from "./auth-layout";
|
||||
|
||||
// Feed Layout
|
||||
export { FeedLayout } from "./feed-layout";
|
||||
export type { FeedLayoutProps } from "./feed-layout";
|
||||
|
||||
@@ -13,16 +13,16 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
|
||||
* Redirects non-managers away; shows loading state while auth resolves.
|
||||
*/
|
||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
const { user } = useAuth();
|
||||
const { user, isInitialized } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null && user.role !== "manager") {
|
||||
if (isInitialized && user !== null && user.role !== "manager") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [user, router]);
|
||||
}, [user, isInitialized, router]);
|
||||
|
||||
if (user === null) {
|
||||
if (!isInitialized || user === null) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||
|
||||
+16
-118
@@ -13,95 +13,17 @@ import type { User } from "./types";
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
isInitialized: boolean;
|
||||
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
|
||||
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);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
@@ -111,32 +33,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const login = async (phone: string, password: string): Promise<boolean> => {
|
||||
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
|
||||
const isPhone = /^0\d{9}$/.test(username.trim());
|
||||
const role = isPhone ? "customer" : "manager";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/customer/login", {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ phone, password }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: username, password, role }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
|
||||
return true;
|
||||
return { ok: true };
|
||||
} else {
|
||||
console.error("Đăng nhập thất bại:", response.statusText);
|
||||
return false;
|
||||
console.error("Đăng nhập thất bại:", response.status);
|
||||
return { ok: false, status: response.status };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi kết nối API:", error);
|
||||
return false;
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,38 +67,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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,
|
||||
setUser,
|
||||
isInitialized,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -595,22 +595,6 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK USERS (for UI demo – replace with real auth) =====
|
||||
export const MOCK_USERS: Record<string, User> = {
|
||||
manager: {
|
||||
id: 1,
|
||||
name: "Nguyễn Văn An",
|
||||
role: "manager",
|
||||
avatar: null,
|
||||
},
|
||||
staff: {
|
||||
id: 2,
|
||||
name: "Trần Thị Bình",
|
||||
role: "staff",
|
||||
avatar: null,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== SHIFT / SCHEDULE DATA =====
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function gqlFetch<T = Record<string, unknown>>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/eatery/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Combo, ComboItem, MenuCategory, Product } from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ManagerTab = "products" | "combos" | "categories";
|
||||
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
|
||||
|
||||
interface ManagerContextType {
|
||||
// Data
|
||||
|
||||
+6
-9
@@ -20,16 +20,13 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const rewrites = [];
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
rewrites.push({
|
||||
const gatewayUrl = "http://localhost:32080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://host.docker.internal:32080/api/:path*",
|
||||
});
|
||||
}
|
||||
|
||||
return rewrites;
|
||||
destination: `${gatewayUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user