fix: connect services to backend (#37)
Release package / release (push) Successful in 7m28s

Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Reviewed-on: #37
Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
This commit was merged in pull request #37.
This commit is contained in:
2026-05-05 14:42:13 +00:00
committed by TakahashiNguyen
parent 48fc033ffa
commit c2afb3d3b5
49 changed files with 1427 additions and 843 deletions
+7 -7
View File
@@ -16,10 +16,10 @@ export default function FeedPage() {
{/* Page title */}
<div className="mb-8">
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
Khám phá quán nước
Explore Shops
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Tìm chọn quán yêu thích của bạn
Find and choose your favorite shop
</p>
</div>
@@ -35,7 +35,7 @@ export default function FeedPage() {
}}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
>
Xóa bộ lọc
Clear filters
</button>
</div>
)}
@@ -46,7 +46,7 @@ export default function FeedPage() {
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
<i className="fa-solid fa-filter text-(--color-primary)"></i>
<span>Lọc quán</span>
<span>Filter shops</span>
</div>
{/* Name search */}
@@ -54,7 +54,7 @@ export default function FeedPage() {
value={searchName}
onChange={setSearchName}
onClear={() => setSearchName("")}
placeholder="Tìm theo tên quán..."
placeholder="Search by shop name..."
className="min-w-0 flex-1"
/>
@@ -65,13 +65,13 @@ export default function FeedPage() {
type="text"
value={searchAddress}
onChange={(e) => setSearchAddress(e.target.value)}
placeholder="Tìm theo địa chỉ..."
placeholder="Search by address..."
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
/>
{searchAddress && (
<button
onClick={() => setSearchAddress("")}
aria-label="Xóa tìm kiếm địa ch"
aria-label="Clear address search"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
+173
View File
@@ -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: "Please enter your OTP code", 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"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ otp: msg, general: "" });
}
} catch {
setErrors({ otp: "", general: "Unable to connect, please try again" });
} 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)">
Verify your phone number
</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>
OTP code sent to <strong>{phone}</strong>
</p>
</div>
{/* OTP Input */}
<div>
<label
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP code
</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="Enter OTP code"
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>
Processing...
</>
) : (
"Sign in"
)}
</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"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+1 -28
View File
@@ -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
Enter your phone number to sign in
</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 :{" "}
<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>
);
+176
View File
@@ -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: "Please enter your password", 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: "Incorrect login details, please try again",
401: "Incorrect password",
403: "Account is locked",
404: "Account does not exist",
};
const msg =
STATUS_ERROR_MAP[res.status] ??
(res.status >= 500
? "Server error, please try again later"
: "An error occurred, please try again");
setErrors({ password: "", general: msg });
}
} catch {
setErrors({ password: "", general: "Unable to connect, please try again" });
} 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)">Manager sign in</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)"
>
Password
</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="Enter password"
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 ? "Hide password" : "Show password"}
>
<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>
Processing...
</>
) : (
"Sign in"
)}
</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"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+196
View File
@@ -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 = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
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: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} 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)">Create a manager account</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">Checking...</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)">Registration closed</h2>
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</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 . 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ử ...</>
) : (
"Đă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"
>
Đã tài khoản? Đăng nhập
</Link>
</div>
</form>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export default function Home() {
}, [activeCategory]);
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
@@ -69,7 +69,7 @@ export default function Home() {
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")}
placeholder="Tìm kiếm món..."
placeholder="Search items..."
className="sm:max-w-xs"
/>
</div>
+6 -6
View File
@@ -46,22 +46,22 @@ export default function PaymentPage() {
<thead>
<tr className="bg-(--color-border-light)/40 text-left">
<th scope="col" className="px-4 py-3 font-semibold">
Tên sản phẩm
Product name
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Giá tiền
Price
</th>
<th scope="col" className="px-4 py-3 font-semibold">
tả
Description
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Số lượng
Quantity
</th>
<th
scope="col"
className="px-4 py-3 text-right font-semibold"
>
Xóa
Delete
</th>
</tr>
</thead>
@@ -116,7 +116,7 @@ export default function PaymentPage() {
style="payment"
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
>
Xóa sản phẩm
Delete product
</Button>
</td>
</tr>
+147 -33
View File
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SubmitEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
// 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 { setUser } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [errors, setErrors] = useState({ phone: "", otp: "" });
const [isLoading, setIsLoading] = useState(false);
const [otpSending, setOtpSending] = useState(false);
const [otpSendError, setOtpSendError] = useState("");
const sendOtp = async () => {
setOtpSending(true);
setOtpSendError("");
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (!res.ok) {
setOtpSendError("Unable to send OTP, please try again");
}
} catch {
setOtpSendError("Unable to connect, please try again");
} finally {
setOtpSending(false);
}
};
useEffect(() => {
if (step === "otp") {
sendOtp();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => {
@@ -28,43 +57,86 @@ export default function RegisterPage() {
return phoneRegex.test(phoneNumber);
};
const handlePhoneSubmit = (e: SubmitEvent<HTMLFormElement>) => {
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!phone.trim()) {
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
setErrors({ ...errors, phone: "Please enter your phone number" });
return;
}
if (!validatePhone(phone)) {
setErrors({
...errors,
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
phone: "Invalid phone number (e.g. 0987654321)",
});
return;
}
// Move to OTP step
setStep("otp");
setIsLoading(true);
setErrors({ phone: "", otp: "" });
try {
const res = await fetch("/api/customer/quick_signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (res.ok) {
setStep("otp");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const phoneErrorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
setErrors({ phone: "Unable to connect, please try again", otp: "" });
} finally {
setIsLoading(false);
}
};
const handleOtpSubmit = (e: SubmitEvent<HTMLFormElement>) => {
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!otp.trim()) {
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
setErrors({ ...errors, otp: "Please enter your OTP code" });
return;
}
if (otp !== DEMO_OTP) {
setErrors({ ...errors, otp: "Mã OTP không đúng" });
return;
}
setIsLoading(true);
setErrors({ phone: "", otp: "" });
// Complete registration
completeRegistration(phone);
router.push("/");
try {
const res = await fetch("/api/customer/quick_login", {
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));
router.push("/");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const msg =
errorCode === "InvalidOTP"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ phone: "", otp: msg });
}
} catch {
setErrors({ phone: "", otp: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
const handleBackToPhone = () => {
@@ -94,8 +166,8 @@ export default function RegisterPage() {
</h1>
<p className="text-sm text-(--color-text-muted)">
{step === "phone"
? "Đăng ký tài khoản khách hàng"
: "Xác thực số điện thoại"}
? "Create a customer account"
: "Verify your phone number"}
</p>
</div>
@@ -131,7 +203,7 @@ export default function RegisterPage() {
htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Số điện thoại
Phone number
</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>
@@ -144,7 +216,8 @@ export default function RegisterPage() {
setErrors({ ...errors, phone: "" });
}}
placeholder="0987654321"
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
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)"} `}
/>
</div>
{errors.phone && (
@@ -154,7 +227,7 @@ export default function RegisterPage() {
</p>
)}
<p className="mt-2 text-xs text-(--color-text-muted)">
Nhập số điện thoại Việt Nam (10 số, bắt đu bằng 0)
Enter a Vietnamese phone number (10 digits, starting with 0)
</p>
</div>
@@ -166,8 +239,16 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
Tiếp tục
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
</Button>
{/* Back to Login */}
@@ -175,7 +256,7 @@ export default function RegisterPage() {
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 active:scale-98"
>
Quay lại đăng nhập
Back to sign in
</Link>
</div>
</form>
@@ -188,7 +269,16 @@ export default function RegisterPage() {
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="mb-2 text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i>
OTP đã đưc gửi đến số <strong>{phone}</strong>
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Sending OTP to <strong>{phone}</strong>...
</>
) : (
<>
OTP code sent to <strong>{phone}</strong>
</>
)}
</p>
<p className="text-xs text-blue-600">
Demo OTP:{" "}
@@ -197,6 +287,12 @@ export default function RegisterPage() {
</code>
</p>
</div>
{otpSendError && (
<p className="flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{otpSendError}
</p>
)}
{/* OTP Input */}
<div>
@@ -204,7 +300,7 @@ export default function RegisterPage() {
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP
OTP code
</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>
@@ -216,9 +312,10 @@ export default function RegisterPage() {
setOtp(e.target.value);
setErrors({ ...errors, otp: "" });
}}
placeholder="Nhập mã OTP"
placeholder="Enter OTP code"
maxLength={6}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
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 && (
@@ -237,8 +334,16 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading || otpSending}
>
Hoàn tất đăng
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Complete registration"
)}
</Button>
{/* Back Button */}
@@ -247,19 +352,28 @@ export default function RegisterPage() {
onClick={handleBackToPhone}
size="lg"
style="login"
disabled={isLoading}
>
Thay đi số điện thoại
Change phone number
</Button>
</div>
{/* Resend OTP (disabled in demo) */}
{/* Resend OTP */}
<div className="text-center">
<button
type="button"
disabled
className="cursor-not-allowed text-sm text-(--color-text-muted)"
onClick={sendOtp}
disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
>
Gửi lại OTP (60s)
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Resending...
</>
) : (
"Resend OTP code"
)}
</button>
</div>
</form>
+32 -32
View File
@@ -28,10 +28,10 @@ import { useMemo, useState } from "react";
// ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "Theo ngày",
week: "Theo tuần",
month: "Theo tháng",
year: "Theo năm",
day: "By day",
week: "By week",
month: "By month",
year: "By year",
};
const CATEGORY_COLORS = [
@@ -67,7 +67,7 @@ const CHART_META: Record<ChartType, { icon: string; label: string }> = {
function CategorySelect({
value,
onChange,
label = "Danh mục:",
label = "Category:",
}: {
value: string;
onChange: (v: string) => void;
@@ -78,12 +78,12 @@ function CategorySelect({
<div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label>
<select
title="Danh mục"
title="Category"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
>
<option value="all">Tất cả</option>
<option value="all">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -181,7 +181,7 @@ export default function AnalyticsPage() {
</span>
<div>
<h1 className="text-foreground text-lg leading-tight font-bold">
Thống & Phân tích tài chính
Financial Statistics & Analytics
</h1>
<p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
@@ -205,7 +205,7 @@ export default function AnalyticsPage() {
</button>
))}
<select
title="Chọn kỳ"
title="Select period"
value={period}
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
@@ -226,12 +226,12 @@ export default function AnalyticsPage() {
{/* ── Summary Cards ── */}
<section>
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
Tổng quan
Overview
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
icon="fa-solid fa-sack-dollar"
title="Tổng doanh thu"
title="Total Revenue"
value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]}
change={revComp.change}
@@ -240,27 +240,27 @@ export default function AnalyticsPage() {
/>
<SummaryCard
icon="fa-solid fa-receipt"
title="Số đơn hàng"
title="Total Orders"
value={totalOrders.toLocaleString()}
subtitle="Tổng đơn trong kỳ"
subtitle="Total orders in period"
change={ordComp.change}
changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-circle-dollar-to-slot"
title="Tổng lợi nhuận"
title="Total Profit"
value={formatCurrency(totalProfit)}
subtitle="Ước tính từ dữ liệu bán hàng"
subtitle="Estimated from sales data"
change={proComp.change}
changePercent={proComp.changePercent}
isPositive={proComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-basket-shopping"
title="Giá trị đơn TB"
title="Avg. Order Value"
value={formatCurrency(avgOrderValue)}
subtitle="Doanh thu / số đơn hàng"
subtitle="Revenue / number of orders"
change={0}
changePercent={0}
isPositive={true}
@@ -273,7 +273,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
Biểu đ doanh thu
Revenue Chart
</h2>
<div className="flex gap-2">
{CHART_TYPES.map((t) => (
@@ -298,7 +298,7 @@ export default function AnalyticsPage() {
{activeChart === "line" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Doanh thu theo thời gian {PERIOD_LABELS[period]}
Revenue over time {PERIOD_LABELS[period]}
</p>
<LineChart data={revenueData} height={220} />
</>
@@ -306,7 +306,7 @@ export default function AnalyticsPage() {
{activeChart === "bar" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
So sánh doanh thu nửa đu nửa sau kỳ hiện tại
Comparing revenue of the first and second half of the current period
</p>
<BarChart
current={barCurrent}
@@ -318,7 +318,7 @@ export default function AnalyticsPage() {
{activeChart === "pie" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Tỷ trọng doanh thu theo danh mục sản phẩm
Revenue share by product category
</p>
<PieChart data={pieData} />
</>
@@ -330,7 +330,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top sản phẩm bán chạy
Top best-selling products
</h2>
<CategorySelect
value={categoryFilter}
@@ -353,7 +353,7 @@ export default function AnalyticsPage() {
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<span className="text-(--color-text-muted) tabular-nums">
{p.unitsSold} ly
{p.unitsSold} cups
</span>
<span className="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)}
@@ -377,17 +377,17 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-table text-foreground mr-2"></i>
Phân tích chi tiết sản phẩm
Detailed product analytics
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Lọc danh mục:"
label="Filter category:"
/>
</div>
<p className="mb-3 text-xs text-(--color-text-muted)">
Click vào tiêu đ cột đ sắp xếp. Hiển thị {filteredSales.length}{" "}
sản phẩm.
Click column headers to sort. Showing {filteredSales.length}{" "}
products.
</p>
<ProductTable data={filteredSales} />
@@ -395,7 +395,7 @@ export default function AnalyticsPage() {
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div>
<span className="text-(--color-text-muted)">
Tổng doanh thu:{" "}
Total revenue:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
@@ -403,7 +403,7 @@ export default function AnalyticsPage() {
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng lợi nhuận:{" "}
Total profit:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
@@ -411,15 +411,15 @@ export default function AnalyticsPage() {
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng sản lượng:{" "}
Total units:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} ly
{filteredUnits.toLocaleString()} cups
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Biên LN trung bình:{" "}
Avg. profit margin:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
+42 -30
View File
@@ -3,6 +3,7 @@
import {
CategoriesTab,
CombosTab,
MenuItemsTab,
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context";
@@ -17,7 +18,7 @@ export default function ManagerPage() {
const tabs = [
{
id: "products" as const,
label: "Thực đơn",
label: "Menu",
icon: "fa-solid fa-utensils",
count: products.length,
},
@@ -29,10 +30,16 @@ export default function ManagerPage() {
},
{
id: "categories" as const,
label: "Danh mục",
label: "Categories",
icon: "fa-solid fa-tags",
count: categories.length,
},
{
id: "menu-items" as const,
label: "Menu",
icon: "fa-solid fa-bowl-food",
count: null,
},
];
return (
@@ -51,7 +58,7 @@ export default function ManagerPage() {
<nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản thực đơn
Menu Management
</p>
{tabs.map((tab) => (
<button
@@ -65,29 +72,31 @@ export default function ManagerPage() {
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
{tab.count !== null && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
)}
</button>
))}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Phân tích
Analytics
</p>
<Link
href="/manager/analytics"
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-chart-line w-4 text-center"></i>
<span className="flex-1 text-left">Tài chính</span>
<span className="flex-1 text-left">Finance</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới
New
</span>
</Link>
<Link
@@ -95,9 +104,9 @@ export default function ManagerPage() {
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
<span className="flex-1 text-left">Ca làm</span>
<span className="flex-1 text-left">Shifts</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới
New
</span>
</Link>
</div>
@@ -110,9 +119,9 @@ export default function ManagerPage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Quản lý"}
{user?.name ?? "Manager"}
</p>
<p className="text-xs text-(--color-text-muted)">Quản quán</p>
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
</div>
</div>
<div className="mt-1 flex gap-2 px-1">
@@ -121,14 +130,14 @@ export default function ManagerPage() {
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
<button
onClick={logout}
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất
Logout
</button>
</div>
</div>
@@ -139,16 +148,18 @@ export default function ManagerPage() {
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
<div>
<h1 className="text-foreground text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"}
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
</h1>
<p className="text-xs text-(--color-text-muted)">
Quản {" "}
Manage{" "}
{activeTab === "products"
? "thực đơn"
? "menu items"
: activeTab === "combos"
? "combo"
: "danh mục"}{" "}
của quán
? "combos"
: activeTab === "categories"
? "categories"
: "menu"}{" "}
for your store
</p>
</div>
@@ -173,7 +184,7 @@ export default function ManagerPage() {
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
>
<i className="fa-solid fa-chart-line"></i>
<span className="hidden sm:inline">Tài chính</span>
<span className="hidden sm:inline">Finance</span>
</Link>
</div>
@@ -184,14 +195,14 @@ export default function ManagerPage() {
className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
>
<i className="fa-solid fa-chart-line"></i>
Thống tài chính
Financial Analytics
</Link>
<Link
href="/"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
</div>
</header>
@@ -200,6 +211,7 @@ export default function ManagerPage() {
{activeTab === "products" && <ProductsTab />}
{activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />}
{activeTab === "menu-items" && <MenuItemsTab />}
</main>
</div>
</div>
+34 -34
View File
@@ -12,18 +12,18 @@ import Link from "next/link";
import { useState } from "react";
const MONTH_NAMES = [
"Tháng 1",
"Tháng 2",
"Tháng 3",
"Tháng 4",
"Tháng 5",
"Tháng 6",
"Tháng 7",
"Tháng 8",
"Tháng 9",
"Tháng 10",
"Tháng 11",
"Tháng 12",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function getMonday(d: Date): Date {
@@ -95,7 +95,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
</div>
<div>
<p className="text-foreground text-sm font-bold">Lịch làm việc</p>
<p className="text-foreground text-sm font-bold">Work Schedule</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Manager" : "Staff"}
</p>
@@ -105,7 +105,7 @@ export default function StaffSchedulePage() {
{/* View toggle */}
<nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Chế đ xem
View
</p>
<button
type="button"
@@ -117,7 +117,7 @@ export default function StaffSchedulePage() {
}`}
>
<i className="fa-solid fa-table-columns w-4 text-center"></i>
<span className="flex-1 text-left">Theo tuần</span>
<span className="flex-1 text-left">Weekly</span>
</button>
<button
type="button"
@@ -129,13 +129,13 @@ export default function StaffSchedulePage() {
}`}
>
<i className="fa-solid fa-calendar w-4 text-center"></i>
<span className="flex-1 text-left">Theo tháng</span>
<span className="flex-1 text-left">Monthly</span>
</button>
{/* Quick nav */}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Điều hướng
Navigation
</p>
<button
type="button"
@@ -143,7 +143,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
<span className="flex-1 text-left">Hôm nay</span>
<span className="flex-1 text-left">Today</span>
</button>
</div>
@@ -151,7 +151,7 @@ export default function StaffSchedulePage() {
{isManager && (
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản
Management
</p>
<Link
href="/manager"
@@ -167,7 +167,7 @@ export default function StaffSchedulePage() {
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<div className="rounded-xl bg-(--color-primary)/5 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngân sách tuần
Weekly Budget
</p>
<p className="mt-1 text-lg font-bold text-(--color-primary)">
{weeklyBudget.toLocaleString("vi-VN")}
@@ -187,10 +187,10 @@ export default function StaffSchedulePage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Nhân viên"}
{user?.name ?? "Staff"}
</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Quản lý" : "Nhân viên"}
{isManager ? "Manager" : "Staff"}
</p>
</div>
</div>
@@ -200,7 +200,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
<button
type="button"
@@ -208,7 +208,7 @@ export default function StaffSchedulePage() {
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất
Logout
</button>
</div>
</div>
@@ -220,7 +220,7 @@ export default function StaffSchedulePage() {
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
<div>
<h1 className="text-foreground text-base font-bold md:text-lg">
Đăng ca làm
Register Shift
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week"
@@ -241,7 +241,7 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tuần
Week
</button>
<button
type="button"
@@ -252,14 +252,14 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tháng
Month
</button>
</div>
{/* Navigation arrows */}
<div className="hidden items-center gap-1 md:flex">
<button
title="Về trước"
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
@@ -271,10 +271,10 @@ export default function StaffSchedulePage() {
onClick={goToToday}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Hôm nay
Today
</button>
<button
title="Tiếp theo"
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
@@ -294,14 +294,14 @@ export default function StaffSchedulePage() {
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
>
<i className="fa-solid fa-plus"></i>
Tạo ca
Create Shift
</button>
)}
{/* Mobile nav */}
<div className="flex items-center gap-1 md:hidden">
<button
title="Về trước"
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -309,7 +309,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
title="Tiếp theo"
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -353,7 +353,7 @@ export default function StaffSchedulePage() {
{/* Mobile FAB for manager */}
{isManager && (
<button
title="Tạo ca"
title="Create Shift"
type="button"
onClick={() => {
setCreateDate(undefined);