Compare commits
7 Commits
main
...
dev-conback
| Author | SHA1 | Date | |
|---|---|---|---|
| df1f32c23d | |||
| 3caa37426e | |||
| cd2de11da0 | |||
| 40b673b9bb | |||
| 582b21ead4 | |||
| 4a43299969 | |||
| 4eaed6c973 |
@@ -16,10 +16,10 @@ export default function FeedPage() {
|
|||||||
{/* Page title */}
|
{/* Page title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
|
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
|
||||||
Khám phá quán nước
|
Explore Shops
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||||
Tìm và chọn quán yêu thích của bạn
|
Find and choose your favorite shop
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export default function FeedPage() {
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
||||||
>
|
>
|
||||||
Xóa bộ lọc
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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)">
|
<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>
|
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
||||||
<span>Lọc quán</span>
|
<span>Filter shops</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name search */}
|
{/* Name search */}
|
||||||
@@ -54,7 +54,7 @@ export default function FeedPage() {
|
|||||||
value={searchName}
|
value={searchName}
|
||||||
onChange={setSearchName}
|
onChange={setSearchName}
|
||||||
onClear={() => setSearchName("")}
|
onClear={() => setSearchName("")}
|
||||||
placeholder="Tìm theo tên quán..."
|
placeholder="Search by shop name..."
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -65,13 +65,13 @@ export default function FeedPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchAddress}
|
value={searchAddress}
|
||||||
onChange={(e) => setSearchAddress(e.target.value)}
|
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)"
|
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 && (
|
{searchAddress && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchAddress("")}
|
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)"
|
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>
|
<i className="fa-solid fa-xmark text-sm"></i>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"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" },
|
||||||
|
credentials: "include",
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,39 +25,12 @@ export default function LoginPage() {
|
|||||||
{SHOP_INFO.name}
|
{SHOP_INFO.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Đăng nhập vào hệ thống
|
Enter your phone number to sign in
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<LoginForm />
|
<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>
|
||||||
</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: "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
Back to Sign In
|
||||||
|
</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)">Cannot Connect</h2>
|
||||||
|
<p className="text-sm text-(--color-text-muted)">Unable to check registration status. Please try again.</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")); }}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<Link href="/login" className="text-sm text-(--color-primary) underline">Back to Sign In</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signup Form */}
|
||||||
|
{pageState === "available" && (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ id: "name", label: "Full Name", icon: "fa-user", placeholder: "John Doe", field: "name" as const, type: "text" },
|
||||||
|
{ id: "phone", label: "Phone Number", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
|
||||||
|
{ id: "password", label: "Password", icon: "fa-lock", placeholder: "At least 6 characters", field: "password" as const, type: "password" },
|
||||||
|
{ id: "eateryName", label: "Restaurant Name", 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>Processing...</>
|
||||||
|
) : (
|
||||||
|
"Sign Up"
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Already have an account? Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -38,7 +38,7 @@ export default function Home() {
|
|||||||
}, [activeCategory]);
|
}, [activeCategory]);
|
||||||
|
|
||||||
const activeCategoryLabel =
|
const activeCategoryLabel =
|
||||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
<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);
|
setSearchQuery(q);
|
||||||
}}
|
}}
|
||||||
onClear={() => setSearchQuery("")}
|
onClear={() => setSearchQuery("")}
|
||||||
placeholder="Tìm kiếm món..."
|
placeholder="Search items..."
|
||||||
className="sm:max-w-xs"
|
className="sm:max-w-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-13
@@ -8,12 +8,13 @@ import { useCart } from "@/lib/cart-context";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const formatPrice = (value: number) =>
|
const formatPrice = (value: number) =>
|
||||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
value.toLocaleString("en-US", { style: "currency", currency: "VND" });
|
||||||
|
|
||||||
export default function PaymentPage() {
|
export default function PaymentPage() {
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
|
eateryId,
|
||||||
increaseQty,
|
increaseQty,
|
||||||
decreaseQty,
|
decreaseQty,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
@@ -32,13 +33,13 @@ export default function PaymentPage() {
|
|||||||
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)">
|
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)">
|
||||||
<div className="border-b border-(--color-border-light) px-4 py-3">
|
<div className="border-b border-(--color-border-light) px-4 py-3">
|
||||||
<h1 className="text-foreground text-lg font-bold md:text-xl">
|
<h1 className="text-foreground text-lg font-bold md:text-xl">
|
||||||
Trang thanh toán
|
Payment page
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="px-4 py-10 text-center text-(--color-text-muted)">
|
<div className="px-4 py-10 text-center text-(--color-text-muted)">
|
||||||
Chưa có sản phẩm nào trong giỏ hàng.
|
There are no products in the shopping cart yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -46,22 +47,22 @@ export default function PaymentPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-(--color-border-light)/40 text-left">
|
<tr className="bg-(--color-border-light)/40 text-left">
|
||||||
<th scope="col" className="px-4 py-3 font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
Tên sản phẩm
|
Product name
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-3 font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
Giá tiền
|
Price
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-3 font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
Mô tả
|
Description
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-3 font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
Số lượng
|
Quantity
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-3 text-right font-semibold"
|
className="px-4 py-3 text-right font-semibold"
|
||||||
>
|
>
|
||||||
Xóa
|
Delete
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -85,7 +86,7 @@ export default function PaymentPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => decreaseQty(item.id)}
|
onClick={() => decreaseQty(item.id)}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||||
aria-label={`Giảm số lượng ${item.name}`}
|
aria-label={`Decrease quantity of ${item.name}`}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
@@ -97,12 +98,12 @@ export default function PaymentPage() {
|
|||||||
setQuantity(item.id, Number(e.target.value))
|
setQuantity(item.id, Number(e.target.value))
|
||||||
}
|
}
|
||||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||||
title="Nhập số lượng"
|
title="Enter quantity"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => increaseQty(item.id)}
|
onClick={() => increaseQty(item.id)}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||||
aria-label={`Tăng số lượng ${item.name}`}
|
aria-label={`Increase quantity of ${item.name}`}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@@ -114,9 +115,9 @@ export default function PaymentPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
size="md"
|
size="md"
|
||||||
style="payment"
|
style="payment"
|
||||||
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
|
aria-label={`Remove ${item.name} from cart`}
|
||||||
>
|
>
|
||||||
Xóa sản phẩm
|
Delete product
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -132,6 +133,7 @@ export default function PaymentPage() {
|
|||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
isCustomer={isCustomer}
|
isCustomer={isCustomer}
|
||||||
backHref="/"
|
backHref="/"
|
||||||
|
eateryId={eateryId ?? undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+148
-33
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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)
|
// Static OTP for demo (in production, this would be sent via SMS)
|
||||||
const DEMO_OTP = "123456";
|
const DEMO_OTP = "123456";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { completeRegistration } = useAuth();
|
const { setUser } = useAuth();
|
||||||
|
|
||||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [otp, setOtp] = useState("");
|
const [otp, setOtp] = useState("");
|
||||||
const [errors, setErrors] = useState({ phone: "", otp: "" });
|
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
|
// Validate Vietnamese phone number
|
||||||
const validatePhone = (phoneNumber: string): boolean => {
|
const validatePhone = (phoneNumber: string): boolean => {
|
||||||
@@ -28,43 +57,87 @@ export default function RegisterPage() {
|
|||||||
return phoneRegex.test(phoneNumber);
|
return phoneRegex.test(phoneNumber);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhoneSubmit = (e: SubmitEvent<HTMLFormElement>) => {
|
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!phone.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validatePhone(phone)) {
|
if (!validatePhone(phone)) {
|
||||||
setErrors({
|
setErrors({
|
||||||
...errors,
|
...errors,
|
||||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
phone: "Invalid phone number (e.g. 0987654321)",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to OTP step
|
setIsLoading(true);
|
||||||
setStep("otp");
|
|
||||||
setErrors({ phone: "", otp: "" });
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!otp.trim()) {
|
if (!otp.trim()) {
|
||||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
setErrors({ ...errors, otp: "Please enter your OTP code" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otp !== DEMO_OTP) {
|
setIsLoading(true);
|
||||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
setErrors({ phone: "", otp: "" });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete registration
|
try {
|
||||||
completeRegistration(phone);
|
const res = await fetch("/api/customer/quick_login", {
|
||||||
router.push("/");
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
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 = () => {
|
const handleBackToPhone = () => {
|
||||||
@@ -94,8 +167,8 @@ export default function RegisterPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
{step === "phone"
|
{step === "phone"
|
||||||
? "Đăng ký tài khoản khách hàng"
|
? "Create a customer account"
|
||||||
: "Xác thực số điện thoại"}
|
: "Verify your phone number"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,7 +204,7 @@ export default function RegisterPage() {
|
|||||||
htmlFor="phone"
|
htmlFor="phone"
|
||||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||||
>
|
>
|
||||||
Số điện thoại
|
Phone number
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<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>
|
<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 +217,8 @@ export default function RegisterPage() {
|
|||||||
setErrors({ ...errors, phone: "" });
|
setErrors({ ...errors, phone: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="0987654321"
|
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>
|
</div>
|
||||||
{errors.phone && (
|
{errors.phone && (
|
||||||
@@ -154,7 +228,7 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,8 +240,16 @@ export default function RegisterPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
style="login"
|
style="login"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Tiếp tục
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Continue"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Back to Login */}
|
{/* Back to Login */}
|
||||||
@@ -175,7 +257,7 @@ export default function RegisterPage() {
|
|||||||
href="/login"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -188,7 +270,16 @@ export default function RegisterPage() {
|
|||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<p className="mb-2 text-sm text-blue-800">
|
<p className="mb-2 text-sm text-blue-800">
|
||||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||||
Mã 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>
|
||||||
<p className="text-xs text-blue-600">
|
<p className="text-xs text-blue-600">
|
||||||
Demo OTP:{" "}
|
Demo OTP:{" "}
|
||||||
@@ -197,6 +288,12 @@ export default function RegisterPage() {
|
|||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* OTP Input */}
|
||||||
<div>
|
<div>
|
||||||
@@ -204,7 +301,7 @@ export default function RegisterPage() {
|
|||||||
htmlFor="otp"
|
htmlFor="otp"
|
||||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||||
>
|
>
|
||||||
Mã OTP
|
OTP code
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<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>
|
<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 +313,10 @@ export default function RegisterPage() {
|
|||||||
setOtp(e.target.value);
|
setOtp(e.target.value);
|
||||||
setErrors({ ...errors, otp: "" });
|
setErrors({ ...errors, otp: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="Nhập mã OTP"
|
placeholder="Enter OTP code"
|
||||||
maxLength={6}
|
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>
|
</div>
|
||||||
{errors.otp && (
|
{errors.otp && (
|
||||||
@@ -237,8 +335,16 @@ export default function RegisterPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
style="login"
|
style="login"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={isLoading || otpSending}
|
||||||
>
|
>
|
||||||
Hoàn tất đăng ký
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Complete registration"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
@@ -247,19 +353,28 @@ export default function RegisterPage() {
|
|||||||
onClick={handleBackToPhone}
|
onClick={handleBackToPhone}
|
||||||
size="lg"
|
size="lg"
|
||||||
style="login"
|
style="login"
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Thay đổi số điện thoại
|
Change phone number
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resend OTP (disabled in demo) */}
|
{/* Resend OTP */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={sendOtp}
|
||||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
disabled={otpSending || isLoading}
|
||||||
|
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
|
||||||
>
|
>
|
||||||
Gửi lại mã OTP (60s)
|
{otpSending ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||||
|
Resending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Resend OTP code"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ import { useMemo, useState } from "react";
|
|||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
||||||
day: "Theo ngày",
|
day: "By day",
|
||||||
week: "Theo tuần",
|
week: "By week",
|
||||||
month: "Theo tháng",
|
month: "By month",
|
||||||
year: "Theo năm",
|
year: "By year",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_COLORS = [
|
const CATEGORY_COLORS = [
|
||||||
@@ -67,7 +67,7 @@ const CHART_META: Record<ChartType, { icon: string; label: string }> = {
|
|||||||
function CategorySelect({
|
function CategorySelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
label = "Danh mục:",
|
label = "Category:",
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -78,12 +78,12 @@ function CategorySelect({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-(--color-text-muted)">{label}</label>
|
<label className="text-xs text-(--color-text-muted)">{label}</label>
|
||||||
<select
|
<select
|
||||||
title="Danh mục"
|
title="Category"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.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"
|
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) => (
|
{categories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -181,7 +181,7 @@ export default function AnalyticsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-foreground text-lg leading-tight font-bold">
|
<h1 className="text-foreground text-lg leading-tight font-bold">
|
||||||
Thống kê & Phân tích tài chính
|
Financial Statistics & Analytics
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
Financial Analytics Dashboard
|
Financial Analytics Dashboard
|
||||||
@@ -205,7 +205,7 @@ export default function AnalyticsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<select
|
<select
|
||||||
title="Chọn kỳ"
|
title="Select period"
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
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"
|
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 ── */}
|
{/* ── Summary Cards ── */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Tổng quan
|
Overview
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon="fa-solid fa-sack-dollar"
|
icon="fa-solid fa-sack-dollar"
|
||||||
title="Tổng doanh thu"
|
title="Total Revenue"
|
||||||
value={formatCurrency(totalRevenue)}
|
value={formatCurrency(totalRevenue)}
|
||||||
subtitle={PERIOD_LABELS[period]}
|
subtitle={PERIOD_LABELS[period]}
|
||||||
change={revComp.change}
|
change={revComp.change}
|
||||||
@@ -240,27 +240,27 @@ export default function AnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon="fa-solid fa-receipt"
|
icon="fa-solid fa-receipt"
|
||||||
title="Số đơn hàng"
|
title="Total Orders"
|
||||||
value={totalOrders.toLocaleString()}
|
value={totalOrders.toLocaleString()}
|
||||||
subtitle="Tổng đơn trong kỳ"
|
subtitle="Total orders in period"
|
||||||
change={ordComp.change}
|
change={ordComp.change}
|
||||||
changePercent={ordComp.changePercent}
|
changePercent={ordComp.changePercent}
|
||||||
isPositive={ordComp.isPositive}
|
isPositive={ordComp.isPositive}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon="fa-solid fa-circle-dollar-to-slot"
|
icon="fa-solid fa-circle-dollar-to-slot"
|
||||||
title="Tổng lợi nhuận"
|
title="Total Profit"
|
||||||
value={formatCurrency(totalProfit)}
|
value={formatCurrency(totalProfit)}
|
||||||
subtitle="Ước tính từ dữ liệu bán hàng"
|
subtitle="Estimated from sales data"
|
||||||
change={proComp.change}
|
change={proComp.change}
|
||||||
changePercent={proComp.changePercent}
|
changePercent={proComp.changePercent}
|
||||||
isPositive={proComp.isPositive}
|
isPositive={proComp.isPositive}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon="fa-solid fa-basket-shopping"
|
icon="fa-solid fa-basket-shopping"
|
||||||
title="Giá trị đơn TB"
|
title="Avg. Order Value"
|
||||||
value={formatCurrency(avgOrderValue)}
|
value={formatCurrency(avgOrderValue)}
|
||||||
subtitle="Doanh thu / số đơn hàng"
|
subtitle="Revenue / number of orders"
|
||||||
change={0}
|
change={0}
|
||||||
changePercent={0}
|
changePercent={0}
|
||||||
isPositive={true}
|
isPositive={true}
|
||||||
@@ -273,7 +273,7 @@ export default function AnalyticsPage() {
|
|||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-foreground text-base font-semibold">
|
<h2 className="text-foreground text-base font-semibold">
|
||||||
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
|
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
|
||||||
Biểu đồ doanh thu
|
Revenue Chart
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{CHART_TYPES.map((t) => (
|
{CHART_TYPES.map((t) => (
|
||||||
@@ -298,7 +298,7 @@ export default function AnalyticsPage() {
|
|||||||
{activeChart === "line" && (
|
{activeChart === "line" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
<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>
|
</p>
|
||||||
<LineChart data={revenueData} height={220} />
|
<LineChart data={revenueData} height={220} />
|
||||||
</>
|
</>
|
||||||
@@ -306,7 +306,7 @@ export default function AnalyticsPage() {
|
|||||||
{activeChart === "bar" && (
|
{activeChart === "bar" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||||
So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại
|
Comparing revenue of the first and second half of the current period
|
||||||
</p>
|
</p>
|
||||||
<BarChart
|
<BarChart
|
||||||
current={barCurrent}
|
current={barCurrent}
|
||||||
@@ -318,7 +318,7 @@ export default function AnalyticsPage() {
|
|||||||
{activeChart === "pie" && (
|
{activeChart === "pie" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
<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>
|
</p>
|
||||||
<PieChart data={pieData} />
|
<PieChart data={pieData} />
|
||||||
</>
|
</>
|
||||||
@@ -330,7 +330,7 @@ export default function AnalyticsPage() {
|
|||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-foreground text-base font-semibold">
|
<h2 className="text-foreground text-base font-semibold">
|
||||||
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
<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>
|
</h2>
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
@@ -353,7 +353,7 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||||
<span className="text-(--color-text-muted) tabular-nums">
|
<span className="text-(--color-text-muted) tabular-nums">
|
||||||
{p.unitsSold} ly
|
{p.unitsSold} cups
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-(--color-primary) tabular-nums">
|
<span className="font-semibold text-(--color-primary) tabular-nums">
|
||||||
{formatCurrency(p.revenue)}
|
{formatCurrency(p.revenue)}
|
||||||
@@ -377,17 +377,17 @@ export default function AnalyticsPage() {
|
|||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-foreground text-base font-semibold">
|
<h2 className="text-foreground text-base font-semibold">
|
||||||
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
<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>
|
</h2>
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={setCategoryFilter}
|
onChange={setCategoryFilter}
|
||||||
label="Lọc danh mục:"
|
label="Filter category:"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
<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}{" "}
|
Click column headers to sort. Showing {filteredSales.length}{" "}
|
||||||
sản phẩm.
|
products.
|
||||||
</p>
|
</p>
|
||||||
<ProductTable data={filteredSales} />
|
<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 className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-(--color-text-muted)">
|
||||||
Tổng doanh thu:{" "}
|
Total revenue:{" "}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-(--color-primary)">
|
<span className="font-semibold text-(--color-primary)">
|
||||||
{formatCurrencyFull(filteredRevenue)}
|
{formatCurrencyFull(filteredRevenue)}
|
||||||
@@ -403,7 +403,7 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-(--color-text-muted)">
|
||||||
Tổng lợi nhuận:{" "}
|
Total profit:{" "}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-green-600">
|
<span className="font-semibold text-green-600">
|
||||||
{formatCurrencyFull(filteredProfit)}
|
{formatCurrencyFull(filteredProfit)}
|
||||||
@@ -411,15 +411,15 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-(--color-text-muted)">
|
||||||
Tổng sản lượng:{" "}
|
Total units:{" "}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{filteredUnits.toLocaleString()} ly
|
{filteredUnits.toLocaleString()} cups
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-(--color-text-muted)">
|
||||||
Biên LN trung bình:{" "}
|
Avg. profit margin:{" "}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-yellow-700">
|
<span className="font-semibold text-yellow-700">
|
||||||
{avgMargin.toFixed(1)}%
|
{avgMargin.toFixed(1)}%
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, TextInput } from "@/components/atoms";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHONE_RE = /^0\d{9}$/;
|
||||||
|
|
||||||
|
function validate(data: FormState): FormErrors {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
if (!data.name.trim()) errors.name = "Name is required";
|
||||||
|
if (!data.phone.trim()) {
|
||||||
|
errors.phone = "Phone number is required";
|
||||||
|
} else if (!PHONE_RE.test(data.phone.trim())) {
|
||||||
|
errors.phone = "Must be 10 digits starting with 0 (e.g. 0912345678)";
|
||||||
|
}
|
||||||
|
if (!data.password) {
|
||||||
|
errors.password = "Password is required";
|
||||||
|
} else if (data.password.length < 6) {
|
||||||
|
errors.password = "Password must be at least 6 characters";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateStaffPage() {
|
||||||
|
const [form, setForm] = useState<FormState>({ name: "", phone: "", password: "" });
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
setApiError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const validation = validate(form);
|
||||||
|
if (Object.keys(validation).length > 0) {
|
||||||
|
setErrors(validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/Staff/signup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
password: form.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
setForm({ name: "", phone: "", password: "" });
|
||||||
|
setErrors({});
|
||||||
|
} else {
|
||||||
|
const text = await res.text();
|
||||||
|
setApiError(text || "Failed to create account. Please try again.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setApiError("Connection error. Please check your network and try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-start justify-center bg-(--color-background) px-4 py-10">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/manager"
|
||||||
|
className="mb-4 inline-flex items-center gap-1.5 text-sm text-(--color-text-muted) no-underline transition hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-arrow-left"></i>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-foreground text-2xl font-bold">Create Staff Account</h1>
|
||||||
|
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||||
|
Fill in the details below to register a new staff member.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-xl bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||||
|
<i className="fa-solid fa-circle-check"></i>
|
||||||
|
Staff account created successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||||
|
<i className="fa-solid fa-circle-exclamation"></i>
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-(--color-border-light) bg-white p-8 shadow-sm">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Enter staff's full name"
|
||||||
|
error={errors.name}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Phone Number"
|
||||||
|
name="phone"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="0xxxxxxxxx"
|
||||||
|
error={errors.phone}
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
error={errors.password}
|
||||||
|
icon={showPassword ? "fa-eye-slash" : "fa-eye"}
|
||||||
|
onIconClick={() => setShowPassword((v) => !v)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
icon="fa-user-plus"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 w-full"
|
||||||
|
>
|
||||||
|
{loading ? "Creating..." : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+235
-33
@@ -3,21 +3,24 @@
|
|||||||
import {
|
import {
|
||||||
CategoriesTab,
|
CategoriesTab,
|
||||||
CombosTab,
|
CombosTab,
|
||||||
|
MenuItemsTab,
|
||||||
ProductsTab,
|
ProductsTab,
|
||||||
} from "@/components/organisms/manager";
|
} from "@/components/organisms/manager";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useManager } from "@/lib/manager-context";
|
import { useManager } from "@/lib/manager-context";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function ManagerPage() {
|
export default function ManagerPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { activeTab, setActiveTab, products, combos, categories } =
|
const { activeTab, setActiveTab, products, combos, categories } =
|
||||||
useManager();
|
useManager();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: "products" as const,
|
id: "products" as const,
|
||||||
label: "Thực đơn",
|
label: "Menu",
|
||||||
icon: "fa-solid fa-utensils",
|
icon: "fa-solid fa-utensils",
|
||||||
count: products.length,
|
count: products.length,
|
||||||
},
|
},
|
||||||
@@ -29,10 +32,16 @@ export default function ManagerPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "categories" as const,
|
id: "categories" as const,
|
||||||
label: "Danh mục",
|
label: "Categories",
|
||||||
icon: "fa-solid fa-tags",
|
icon: "fa-solid fa-tags",
|
||||||
count: categories.length,
|
count: categories.length,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "menu-items" as const,
|
||||||
|
label: "Menu",
|
||||||
|
icon: "fa-solid fa-bowl-food",
|
||||||
|
count: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +60,7 @@ export default function ManagerPage() {
|
|||||||
|
|
||||||
<nav className="flex-1 space-y-1 p-3">
|
<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">
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Quản lý thực đơn
|
Menu Management
|
||||||
</p>
|
</p>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -65,29 +74,31 @@ export default function ManagerPage() {
|
|||||||
>
|
>
|
||||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||||
<span className="flex-1 text-left">{tab.label}</span>
|
<span className="flex-1 text-left">{tab.label}</span>
|
||||||
<span
|
{tab.count !== null && (
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
<span
|
||||||
activeTab === tab.id
|
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||||
? "bg-white/20 text-white"
|
activeTab === tab.id
|
||||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
? "bg-white/20 text-white"
|
||||||
}`}
|
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||||
>
|
}`}
|
||||||
{tab.count}
|
>
|
||||||
</span>
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
<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">
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Phân tích
|
Analytics
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/manager/analytics"
|
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)"
|
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>
|
<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)">
|
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||||
Mới
|
New
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -95,12 +106,24 @@ 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)"
|
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>
|
<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)">
|
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||||
Mới
|
New
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
||||||
|
Staff Management
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/manager/create-staff"
|
||||||
|
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-user-plus w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Create Staff Account</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-(--color-border-light) p-3">
|
<div className="border-t border-(--color-border-light) p-3">
|
||||||
@@ -110,9 +133,9 @@ export default function ManagerPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground truncate text-sm font-semibold">
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
{user?.name ?? "Quản lý"}
|
{user?.name ?? "Manager"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-(--color-text-muted)">Quản lý quán</p>
|
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex gap-2 px-1">
|
<div className="mt-1 flex gap-2 px-1">
|
||||||
@@ -121,14 +144,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"
|
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>
|
<i className="fa-solid fa-house"></i>
|
||||||
Trang chủ
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
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"
|
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>
|
<i className="fa-solid fa-right-from-bracket"></i>
|
||||||
Đăng xuất
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,21 +162,23 @@ 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">
|
<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>
|
<div>
|
||||||
<h1 className="text-foreground text-lg font-bold">
|
<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>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
Quản lý{" "}
|
Manage{" "}
|
||||||
{activeTab === "products"
|
{activeTab === "products"
|
||||||
? "thực đơn"
|
? "menu items"
|
||||||
: activeTab === "combos"
|
: activeTab === "combos"
|
||||||
? "combo"
|
? "combos"
|
||||||
: "danh mục"}{" "}
|
: activeTab === "categories"
|
||||||
của quán
|
? "categories"
|
||||||
|
: "menu"}{" "}
|
||||||
|
for your store
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile tabs */}
|
{/* sm–lg: icon row */}
|
||||||
<div className="flex items-center gap-1 lg:hidden">
|
<div className="hidden items-center gap-1 sm:flex lg:hidden">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -165,7 +190,7 @@ export default function ManagerPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<i className={tab.icon}></i>
|
<i className={tab.icon}></i>
|
||||||
<span className="hidden sm:inline">{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Link
|
<Link
|
||||||
@@ -173,8 +198,177 @@ 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"
|
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>
|
<i className="fa-solid fa-chart-line"></i>
|
||||||
<span className="hidden sm:inline">Tài chính</span>
|
<span>Finance</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/manager/create-staff"
|
||||||
|
className="bg-background flex items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-plus"></i>
|
||||||
|
<span>Staff</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* User menu dropdown (sm–lg) */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||||
|
mobileMenuOpen
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-tie"></i>
|
||||||
|
</button>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||||
|
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-foreground truncate text-xs font-semibold">
|
||||||
|
{user?.name ?? "Manager"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-(--color-text-muted)">
|
||||||
|
Store Manager
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-1 border-t border-(--color-border-light)" />
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-house w-4 text-center"></i>
|
||||||
|
Return to home
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* xs (< sm): single hamburger → full dropdown */}
|
||||||
|
<div className="relative sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium transition ${
|
||||||
|
mobileMenuOpen
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={
|
||||||
|
mobileMenuOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
|
||||||
|
}
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-52 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
|
||||||
|
{/* User info */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||||
|
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-foreground truncate text-xs font-semibold">
|
||||||
|
{user?.name ?? "Manager"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-(--color-text-muted)">
|
||||||
|
Store Manager
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-1 border-t border-(--color-border-light)" />
|
||||||
|
{/* Tabs */}
|
||||||
|
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Menu Management
|
||||||
|
</p>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.id);
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full cursor-pointer items-center gap-2 rounded-lg border-none px-3 py-2 text-xs font-medium transition ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-background)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||||
|
<span className="flex-1 text-left">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="my-1 border-t border-(--color-border-light)" />
|
||||||
|
{/* Analytics */}
|
||||||
|
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Analytics
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/manager/analytics"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||||
|
Finance
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/staff/schedule"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||||
|
Shifts
|
||||||
|
</Link>
|
||||||
|
<div className="my-1 border-t border-(--color-border-light)" />
|
||||||
|
{/* Staff Management */}
|
||||||
|
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Staff Management
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/manager/create-staff"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-plus w-4 text-center"></i>
|
||||||
|
Create Staff Account
|
||||||
|
</Link>
|
||||||
|
<div className="my-1 border-t border-(--color-border-light)" />
|
||||||
|
{/* Actions */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-house w-4 text-center"></i>
|
||||||
|
Return to home
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop actions */}
|
{/* Desktop actions */}
|
||||||
@@ -184,14 +378,21 @@ 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"
|
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>
|
<i className="fa-solid fa-chart-line"></i>
|
||||||
Thống kê tài chính
|
Financial Analytics
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/manager/create-staff"
|
||||||
|
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 hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-plus"></i>
|
||||||
|
Create Staff
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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"
|
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>
|
<i className="fa-solid fa-house"></i>
|
||||||
Trang chủ
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -200,6 +401,7 @@ export default function ManagerPage() {
|
|||||||
{activeTab === "products" && <ProductsTab />}
|
{activeTab === "products" && <ProductsTab />}
|
||||||
{activeTab === "combos" && <CombosTab />}
|
{activeTab === "combos" && <CombosTab />}
|
||||||
{activeTab === "categories" && <CategoriesTab />}
|
{activeTab === "categories" && <CategoriesTab />}
|
||||||
|
{activeTab === "menu-items" && <MenuItemsTab />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1",
|
"January",
|
||||||
"Tháng 2",
|
"February",
|
||||||
"Tháng 3",
|
"March",
|
||||||
"Tháng 4",
|
"April",
|
||||||
"Tháng 5",
|
"May",
|
||||||
"Tháng 6",
|
"June",
|
||||||
"Tháng 7",
|
"July",
|
||||||
"Tháng 8",
|
"August",
|
||||||
"Tháng 9",
|
"September",
|
||||||
"Tháng 10",
|
"October",
|
||||||
"Tháng 11",
|
"November",
|
||||||
"Tháng 12",
|
"December",
|
||||||
];
|
];
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
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>
|
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
<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)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{isManager ? "Manager" : "Staff"}
|
{isManager ? "Manager" : "Staff"}
|
||||||
</p>
|
</p>
|
||||||
@@ -105,7 +105,7 @@ export default function StaffSchedulePage() {
|
|||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<nav className="flex-1 space-y-1 p-3">
|
<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">
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Chế độ xem
|
View
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -117,7 +117,7 @@ export default function StaffSchedulePage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-table-columns w-4 text-center"></i>
|
<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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -129,13 +129,13 @@ export default function StaffSchedulePage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-calendar w-4 text-center"></i>
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Quick nav */}
|
{/* Quick nav */}
|
||||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
<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">
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Điều hướng
|
Navigation
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="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)"
|
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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ export default function StaffSchedulePage() {
|
|||||||
{isManager && (
|
{isManager && (
|
||||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
<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">
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
Quản lý
|
Management
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/manager"
|
href="/manager"
|
||||||
@@ -167,7 +167,7 @@ export default function StaffSchedulePage() {
|
|||||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||||
<div className="rounded-xl bg-(--color-primary)/5 p-3">
|
<div className="rounded-xl bg-(--color-primary)/5 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
Ngân sách tuần
|
Weekly Budget
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-bold text-(--color-primary)">
|
<p className="mt-1 text-lg font-bold text-(--color-primary)">
|
||||||
{weeklyBudget.toLocaleString("vi-VN")}
|
{weeklyBudget.toLocaleString("vi-VN")}
|
||||||
@@ -187,10 +187,10 @@ export default function StaffSchedulePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground truncate text-sm font-semibold">
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
{user?.name ?? "Nhân viên"}
|
{user?.name ?? "Staff"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{isManager ? "Quản lý" : "Nhân viên"}
|
{isManager ? "Manager" : "Staff"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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>
|
<i className="fa-solid fa-house"></i>
|
||||||
Trang chủ
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
<i className="fa-solid fa-right-from-bracket"></i>
|
||||||
Đăng xuất
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<div>
|
||||||
<h1 className="text-foreground text-base font-bold md:text-lg">
|
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||||
Đăng ký ca làm
|
Register Shift
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{view === "week"
|
{view === "week"
|
||||||
@@ -241,7 +241,7 @@ export default function StaffSchedulePage() {
|
|||||||
: "bg-transparent text-(--color-text-secondary)"
|
: "bg-transparent text-(--color-text-secondary)"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Tuần
|
Week
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -252,14 +252,14 @@ export default function StaffSchedulePage() {
|
|||||||
: "bg-transparent text-(--color-text-secondary)"
|
: "bg-transparent text-(--color-text-secondary)"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Tháng
|
Month
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation arrows */}
|
{/* Navigation arrows */}
|
||||||
<div className="hidden items-center gap-1 md:flex">
|
<div className="hidden items-center gap-1 md:flex">
|
||||||
<button
|
<button
|
||||||
title="Về trước"
|
title="Previous"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
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"
|
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}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
title="Tiếp theo"
|
title="Next"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
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"
|
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"
|
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>
|
<i className="fa-solid fa-plus"></i>
|
||||||
Tạo ca
|
Create Shift
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile nav */}
|
{/* Mobile nav */}
|
||||||
<div className="flex items-center gap-1 md:hidden">
|
<div className="flex items-center gap-1 md:hidden">
|
||||||
<button
|
<button
|
||||||
title="Về trước"
|
title="Previous"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
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)"
|
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>
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Tiếp theo"
|
title="Next"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
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)"
|
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 */}
|
{/* Mobile FAB for manager */}
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<button
|
<button
|
||||||
title="Tạo ca"
|
title="Create Shift"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateDate(undefined);
|
setCreateDate(undefined);
|
||||||
|
|||||||
+3
-3
@@ -18,8 +18,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Coffee Shop — Hệ thống đặt món",
|
title: "Coffee Shop — Order System",
|
||||||
description: "Đặt món cà phê, trà, nước ép và nhiều hơn nữa tại Coffee Shop.",
|
description: "Order coffee, tea, juice and more at Coffee Shop.",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon/favicon.ico",
|
icon: "/favicon/favicon.ico",
|
||||||
shortcut: "/favicon/favicon.ico",
|
shortcut: "/favicon/favicon.ico",
|
||||||
@@ -33,7 +33,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="vi">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{/* FontAwesome 6 — icons used throughout the app */}
|
{/* FontAwesome 6 — icons used throughout the app */}
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -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";
|
export type { ButtonProps } from "./buttons";
|
||||||
|
|
||||||
// Inputs
|
// Inputs
|
||||||
export { TextInput, SearchInput, Textarea } from "./inputs";
|
export { TextInput, Textarea } from "./inputs";
|
||||||
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
|
export type { TextInputProps, TextareaProps } from "./inputs";
|
||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
export { Heading, Text, Caption } from "./typography";
|
export { Heading, Text, Caption } from "./typography";
|
||||||
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
|
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
|
||||||
|
|
||||||
// Badges
|
|
||||||
export { Badge, PriceBadge } from "./badges";
|
|
||||||
export type { BadgeProps } from "./badges";
|
|
||||||
|
|
||||||
// Dividers
|
// Dividers
|
||||||
export { Divider } from "./dividers";
|
export { Divider } from "./dividers";
|
||||||
export type { DividerProps } from "./dividers";
|
export type { DividerProps } from "./dividers";
|
||||||
|
|||||||
@@ -13,17 +13,3 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|||||||
error?: string;
|
error?: string;
|
||||||
className?: 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 TextInput } from "./TextInput";
|
||||||
export { default as SearchInput } from "./SearchInput";
|
|
||||||
export { default as Textarea } from "./Textarea";
|
export { default as Textarea } from "./Textarea";
|
||||||
export type {
|
export type { TextInputProps, TextareaProps } from "./Input.types";
|
||||||
TextInputProps,
|
|
||||||
SearchInputProps,
|
|
||||||
TextareaProps,
|
|
||||||
LoginInputProps,
|
|
||||||
} from "./Input.types";
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface ProductCardProps {
|
|||||||
|
|
||||||
export interface ShopCardProps {
|
export interface ShopCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
|
eateryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
image: string;
|
image: string;
|
||||||
@@ -21,5 +22,6 @@ export interface PaymentSummaryCardProps {
|
|||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
isCustomer: boolean;
|
isCustomer: boolean;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
|
eateryId?: string;
|
||||||
onPay?: () => void;
|
onPay?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function PaymentSummaryCard({
|
|||||||
totalPrice,
|
totalPrice,
|
||||||
isCustomer = false,
|
isCustomer = false,
|
||||||
backHref,
|
backHref,
|
||||||
|
eateryId,
|
||||||
}: PaymentSummaryCardProps) {
|
}: PaymentSummaryCardProps) {
|
||||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||||
const handlePayment = () => {
|
const handlePayment = () => {
|
||||||
@@ -23,10 +24,10 @@ export default function PaymentSummaryCard({
|
|||||||
return (
|
return (
|
||||||
<aside className="shrink-0 xl:w-85">
|
<aside className="shrink-0 xl:w-85">
|
||||||
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
||||||
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2>
|
<h2 className="mb-4 text-lg font-bold">Bill</h2>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
||||||
<span className="text-(--color-text-muted)">Tổng cộng</span>
|
<span className="text-(--color-text-muted)">Total</span>
|
||||||
<span className="text-xl font-bold text-(--color-primary)">
|
<span className="text-xl font-bold text-(--color-primary)">
|
||||||
{formatPrice(totalPrice)}
|
{formatPrice(totalPrice)}
|
||||||
</span>
|
</span>
|
||||||
@@ -40,7 +41,7 @@ export default function PaymentSummaryCard({
|
|||||||
size="md"
|
size="md"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
Tiền mặt
|
Cash
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -61,7 +62,7 @@ export default function PaymentSummaryCard({
|
|||||||
size="md"
|
size="md"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
Đánh giá
|
Review
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export default function PaymentSummaryCard({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Quay về
|
Return
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,6 +87,7 @@ export default function PaymentSummaryCard({
|
|||||||
<ReviewModal
|
<ReviewModal
|
||||||
isOpen={isReviewOpen}
|
isOpen={isReviewOpen}
|
||||||
onClose={() => setIsReviewOpen(false)}
|
onClose={() => setIsReviewOpen(false)}
|
||||||
|
eateryId={eateryId}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type { ProductCardProps } from "./Card.types";
|
|||||||
*/
|
*/
|
||||||
export default function ProductCard({
|
export default function ProductCard({
|
||||||
image,
|
image,
|
||||||
imageAlt = "Ảnh sản phẩm",
|
imageAlt = "Product image",
|
||||||
productName,
|
productName,
|
||||||
price,
|
price,
|
||||||
description,
|
description,
|
||||||
@@ -67,9 +67,9 @@ export default function ProductCard({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="fa-cart-plus"
|
icon="fa-cart-plus"
|
||||||
aria-label={`Mua ${productName}`}
|
aria-label={`Buy ${productName}`}
|
||||||
>
|
>
|
||||||
Mua
|
Buy
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,22 +11,22 @@ const STATUS_STYLES: Record<
|
|||||||
available: {
|
available: {
|
||||||
bg: "bg-blue-50 border-blue-200",
|
bg: "bg-blue-50 border-blue-200",
|
||||||
text: "text-blue-700",
|
text: "text-blue-700",
|
||||||
label: "Còn trống",
|
label: "Available",
|
||||||
},
|
},
|
||||||
registered: {
|
registered: {
|
||||||
bg: "bg-blue-100 border-blue-400",
|
bg: "bg-blue-100 border-blue-400",
|
||||||
text: "text-blue-900",
|
text: "text-blue-900",
|
||||||
label: "Đã đăng ký",
|
label: "Registered",
|
||||||
},
|
},
|
||||||
approved_leave: {
|
approved_leave: {
|
||||||
bg: "bg-purple-50 border-purple-300",
|
bg: "bg-purple-50 border-purple-300",
|
||||||
text: "text-purple-700",
|
text: "text-purple-700",
|
||||||
label: "Nghỉ phép",
|
label: "On Leave",
|
||||||
},
|
},
|
||||||
absent: {
|
absent: {
|
||||||
bg: "bg-red-50 border-red-300",
|
bg: "bg-red-50 border-red-300",
|
||||||
text: "text-red-700",
|
text: "text-red-700",
|
||||||
label: "Vắng mặt",
|
label: "Absent",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export default function ShiftCard({
|
|||||||
{shift.registeredStaff.length > 0 && (
|
{shift.registeredStaff.length > 0 && (
|
||||||
<div className="mt-2 border-t border-current/10 pt-2">
|
<div className="mt-2 border-t border-current/10 pt-2">
|
||||||
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
||||||
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
Staff ({shift.registeredStaff.length}/{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{shift.registeredStaff.map((s) => (
|
{shift.registeredStaff.map((s) => (
|
||||||
@@ -111,7 +111,7 @@ export default function ShiftCard({
|
|||||||
|
|
||||||
{shift.status === "available" && shift.registeredStaff.length === 0 && (
|
{shift.status === "available" && shift.registeredStaff.length === 0 && (
|
||||||
<p className="mt-2 text-[10px] italic opacity-50">
|
<p className="mt-2 text-[10px] italic opacity-50">
|
||||||
{shift.maxStaff} vị trí còn trống
|
{shift.maxStaff} spots available
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import type { ShopCardProps } from "./Card.types";
|
import type { ShopCardProps } from "./Card.types";
|
||||||
|
|
||||||
export default function ShopCard({ name, address, image }: ShopCardProps) {
|
export default function ShopCard({ name, address, image, eateryId }: ShopCardProps) {
|
||||||
|
const { setEateryId } = useCart();
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]">
|
<div className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]">
|
||||||
{/* Shop image */}
|
{/* Shop image */}
|
||||||
@@ -25,10 +29,11 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
onClick={() => setEateryId(eateryId)}
|
||||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-book-open text-[10px]"></i>
|
<i className="fa-solid fa-book-open text-[10px]"></i>
|
||||||
Xem menu
|
View menu
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function SearchBar({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onClear,
|
onClear,
|
||||||
placeholder = "Tìm kiếm...",
|
placeholder = "Search...",
|
||||||
className = "",
|
className = "",
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
@@ -17,14 +17,14 @@ export default function SearchBar({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Tìm kiếm món ăn"
|
aria-label="Search items"
|
||||||
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
title="Xóa tìm kiếm"
|
title="Clear search"
|
||||||
aria-label="Xóa tìm kiếm"
|
aria-label="Clear 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)"
|
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>
|
<i className="fa-solid fa-xmark text-sm"></i>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
fontSize="10"
|
fontSize="10"
|
||||||
fill="#F0D9A8"
|
fill="#F0D9A8"
|
||||||
>
|
>
|
||||||
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
|
{d.label} ({hovered.set === "cur" ? "Current" : "Previous"})
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={tipX + tipW / 2}
|
x={tipX + tipW / 2}
|
||||||
@@ -181,7 +181,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
fontSize="10"
|
fontSize="10"
|
||||||
fill="#A08060"
|
fill="#A08060"
|
||||||
>
|
>
|
||||||
{d.orders} đơn hàng
|
{d.orders} orders
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
@@ -190,7 +190,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
||||||
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
|
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
|
||||||
Hiện tại
|
Current
|
||||||
</text>
|
</text>
|
||||||
<rect
|
<rect
|
||||||
x={padL + 65}
|
x={padL + 65}
|
||||||
@@ -201,7 +201,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
fill="#E2C9A8"
|
fill="#E2C9A8"
|
||||||
/>
|
/>
|
||||||
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
|
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
|
||||||
Kỳ trước
|
Previous
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
|
|||||||
fontSize="10"
|
fontSize="10"
|
||||||
fill="#A08060"
|
fill="#A08060"
|
||||||
>
|
>
|
||||||
{p.data.orders} đơn
|
{p.data.orders} orders
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,21 +76,21 @@ export function ProductTable({ data }: ProductTableProps) {
|
|||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
Sản phẩm
|
Product
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
Danh mục
|
Category
|
||||||
</th>
|
</th>
|
||||||
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
|
<SortTh col="unitsSold" label="Units Sold" className="text-right" />
|
||||||
<SortTh col="revenue" label="Doanh thu" className="text-right" />
|
<SortTh col="revenue" label="Revenue" className="text-right" />
|
||||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
Giá nhập
|
Cost Price
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
Giá bán
|
Selling Price
|
||||||
</th>
|
</th>
|
||||||
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
|
<SortTh col="profit" label="Profit" className="text-right" />
|
||||||
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
|
<SortTh col="profitMargin" label="Margin" className="text-right" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function SummaryCard({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-normal text-(--color-text-muted)">
|
<span className="text-xs font-normal text-(--color-text-muted)">
|
||||||
({isPositive ? "+" : ""}
|
({isPositive ? "+" : ""}
|
||||||
{formatCurrency(change)}) so với kỳ trước
|
{formatCurrency(change)}) vs previous period
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function CartFab() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/payment"
|
href="/payment"
|
||||||
aria-label="Đi đến trang thanh toán"
|
aria-label="Go to payment page"
|
||||||
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-cart-shopping text-lg"></i>
|
<i className="fa-solid fa-cart-shopping text-lg"></i>
|
||||||
|
|||||||
@@ -1,122 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { SubmitEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login } = useAuth();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errors, setErrors] = useState({
|
const [errors, setErrors] = useState({ phone: "", general: "" });
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
general: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
const newErrors = { username: "", password: "", general: "" };
|
if (!phone.trim()) {
|
||||||
let isValid = true;
|
setErrors({ phone: "Please enter your phone number", general: "" });
|
||||||
|
return false;
|
||||||
if (!username.trim()) {
|
|
||||||
newErrors.username = "Vui lòng nhập tên đăng nhập";
|
|
||||||
isValid = false;
|
|
||||||
}
|
}
|
||||||
|
if (!PHONE_REGEX.test(phone)) {
|
||||||
if (!password.trim()) {
|
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
|
||||||
newErrors.password = "Vui lòng nhập mật khẩu";
|
return false;
|
||||||
isValid = false;
|
|
||||||
} else if (password.length < 4) {
|
|
||||||
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
|
|
||||||
isValid = false;
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
setErrors(newErrors);
|
|
||||||
return isValid;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
|
||||||
const success = await login(username, password);
|
setIsLoading(true);
|
||||||
|
setErrors({ phone: "", general: "" });
|
||||||
|
|
||||||
if (success) {
|
try {
|
||||||
router.push("/");
|
const res = await fetch("/api/sms_otp", {
|
||||||
} else {
|
method: "POST",
|
||||||
setErrors({
|
headers: { "Content-Type": "application/json" },
|
||||||
username: "",
|
body: JSON.stringify({ phone }),
|
||||||
password: "",
|
|
||||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: "Phone number not registered" });
|
||||||
|
} else {
|
||||||
|
setErrors({ phone: "", general: "An error occurred, please try again" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrors({ phone: "", general: "Unable to connect, please try again" });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Error Message */}
|
|
||||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
{/* Username Input */}
|
<label
|
||||||
<LoginInput
|
htmlFor="phone"
|
||||||
label="Tên đăng nhập"
|
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||||
type="text"
|
>
|
||||||
name="username"
|
Phone number
|
||||||
value={username}
|
</label>
|
||||||
onChange={(e) => {
|
<div className="relative">
|
||||||
setUsername(e.target.value);
|
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||||
setErrors({ ...errors, username: "", general: "" });
|
<input
|
||||||
}}
|
id="phone"
|
||||||
errors={errors.username}
|
type="tel"
|
||||||
/>
|
value={phone}
|
||||||
{errors.username && (
|
onChange={(e) => {
|
||||||
<ErrorMessageLogin message={errors.username} type="secondary" />
|
setPhone(e.target.value);
|
||||||
|
setErrors({ phone: "", general: "" });
|
||||||
|
}}
|
||||||
|
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)"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.phone && (
|
||||||
|
<ErrorMessageLogin message={errors.phone} type="secondary" />
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||||
|
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||||
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-3 pt-2">
|
||||||
{/* Login Button */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="primaryNoBorder"
|
variant="primaryNoBorder"
|
||||||
type="submit"
|
type="submit"
|
||||||
style="login"
|
style="login"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Đăng nhập
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Continue"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Register Button */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Đăng ký tài khoản
|
Create account
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ export default function CategoriesTab() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
<strong className="text-foreground">{categories.length}</strong> danh
|
<strong className="text-foreground">{categories.length}</strong>{" "}
|
||||||
mục
|
categories
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalCategory("new")}
|
onClick={() => setModalCategory("new")}
|
||||||
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"
|
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>
|
<i className="fa-solid fa-plus"></i>
|
||||||
<span className="hidden sm:inline">Thêm danh mục</span>
|
<span className="hidden sm:inline">Add category</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,19 +50,19 @@ export default function CategoriesTab() {
|
|||||||
<p className="text-foreground truncate font-semibold">
|
<p className="text-foreground truncate font-semibold">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-(--color-text-muted)">{count} món</p>
|
<p className="text-xs text-(--color-text-muted)">{count} items</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalCategory(cat)}
|
onClick={() => setModalCategory(cat)}
|
||||||
title="Chỉnh sửa"
|
title="Edit"
|
||||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-pen text-[11px]"></i>
|
<i className="fa-solid fa-pen text-[11px]"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(cat)}
|
onClick={() => setDeleteTarget(cat)}
|
||||||
title="Xóa"
|
title="Delete"
|
||||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-trash text-[11px]"></i>
|
<i className="fa-solid fa-trash text-[11px]"></i>
|
||||||
@@ -75,7 +75,7 @@ export default function CategoriesTab() {
|
|||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
|
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
|
||||||
<p className="text-sm">Chưa có danh mục nào</p>
|
<p className="text-sm">No categories yet</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function CategoryModal({
|
|||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||||
<h2 className="text-foreground text-lg font-bold">
|
<h2 className="text-foreground text-lg font-bold">
|
||||||
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"}
|
{isEdit ? "Edit category" : "Add new category"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
title="Close"
|
title="Close"
|
||||||
@@ -66,7 +66,7 @@ export default function CategoryModal({
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Tên danh mục <span className="text-red-500">*</span>
|
Category name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -74,7 +74,7 @@ export default function CategoryModal({
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||||
placeholder="Ví dụ: Cà Phê"
|
placeholder="e.g. Coffee"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,13 +107,13 @@ export default function CategoryModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||||
>
|
>
|
||||||
Hủy
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||||
>
|
>
|
||||||
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
|
{isEdit ? "Save change" : "Add category"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
|||||||
import type { ComboModalProps } from "./Manager.types";
|
import type { ComboModalProps } from "./Manager.types";
|
||||||
|
|
||||||
function formatPrice(price: number) {
|
function formatPrice(price: number) {
|
||||||
return price.toLocaleString("vi-VN") + "đ";
|
return price.toLocaleString("en-US") + "₫";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComboModal({
|
export default function ComboModal({
|
||||||
@@ -74,7 +74,7 @@ export default function ComboModal({
|
|||||||
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
|
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||||
<h2 className="text-foreground text-lg font-bold">
|
<h2 className="text-foreground text-lg font-bold">
|
||||||
{isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"}
|
{isEdit ? "Edit Combo" : "Add New Combo"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
title="Close"
|
title="Close"
|
||||||
@@ -92,7 +92,7 @@ export default function ComboModal({
|
|||||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Tên combo <span className="text-red-500">*</span>
|
Combo Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -100,16 +100,16 @@ export default function ComboModal({
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
placeholder="Ví dụ: Combo Cà Phê Đôi"
|
placeholder="e.g. Double Coffee Combo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Giá combo (đ) <span className="text-red-500">*</span>
|
Price (VND) <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="Giá combo"
|
title="Price in Vietnamese đồng"
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -124,10 +124,10 @@ export default function ComboModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Mô tả
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
title="Mô tả combo"
|
title="Description"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -139,10 +139,10 @@ export default function ComboModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Món trong combo{" "}
|
Items in the combo{" "}
|
||||||
{form.items.length === 0 && (
|
{form.items.length === 0 && (
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
(Chọn ít nhất 1 món)
|
(Choose at least one item.)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
@@ -162,7 +162,7 @@ export default function ComboModal({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
title="Giảm"
|
title="Decrease"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateItemQty(p.id, qty - 1)}
|
onClick={() => updateItemQty(p.id, qty - 1)}
|
||||||
disabled={qty === 0}
|
disabled={qty === 0}
|
||||||
@@ -174,7 +174,7 @@ export default function ComboModal({
|
|||||||
{qty}
|
{qty}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
title="Tăng"
|
title="Increase"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateItemQty(p.id, qty + 1)}
|
onClick={() => updateItemQty(p.id, qty + 1)}
|
||||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
@@ -191,14 +191,14 @@ export default function ComboModal({
|
|||||||
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-foreground text-sm font-medium">
|
<p className="text-foreground text-sm font-medium">
|
||||||
Trạng thái
|
Status
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{form.available ? "Còn hàng" : "Tạm hết"}
|
{form.available ? "In stock" : "Temporarily out of stock"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
title="Chuyển đổi trạng thái"
|
title="Toggle status"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm({ ...form, available: !form.available })}
|
onClick={() => setForm({ ...form, available: !form.available })}
|
||||||
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
||||||
@@ -220,14 +220,14 @@ export default function ComboModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||||
>
|
>
|
||||||
Hủy
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={form.items.length === 0}
|
disabled={form.items.length === 0}
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
|
{isEdit ? "Save change" : "Add combo"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import DeleteConfirm from "./DeleteConfirm";
|
|||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
function formatPrice(price: number) {
|
function formatPrice(price: number) {
|
||||||
return price.toLocaleString("vi-VN") + "đ";
|
return price.toLocaleString("en-US") + "₫";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CombosTab() {
|
export default function CombosTab() {
|
||||||
@@ -26,7 +26,7 @@ export default function CombosTab() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
|
||||||
|
|
||||||
const getProductName = (id: number) =>
|
const getProductName = (id: number) =>
|
||||||
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
|
products.find((p) => p.id === id)?.name ?? `Item #${id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -39,7 +39,7 @@ export default function CombosTab() {
|
|||||||
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"
|
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>
|
<i className="fa-solid fa-plus"></i>
|
||||||
<span className="hidden sm:inline">Thêm combo</span>
|
<span className="hidden sm:inline">Add new combo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export default function CombosTab() {
|
|||||||
{combos.length === 0 ? (
|
{combos.length === 0 ? (
|
||||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
|
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
|
||||||
<p className="text-sm">Chưa có combo nào</p>
|
<p className="text-sm">No combos available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
combos.map((combo) => (
|
combos.map((combo) => (
|
||||||
@@ -69,7 +69,7 @@ export default function CombosTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => toggleComboAvailability(combo.id)}
|
onClick={() => toggleComboAvailability(combo.id)}
|
||||||
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
|
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
|
||||||
title="Đổi trạng thái"
|
title="Toggle status"
|
||||||
>
|
>
|
||||||
<StatusBadge available={combo.available} />
|
<StatusBadge available={combo.available} />
|
||||||
</button>
|
</button>
|
||||||
@@ -77,7 +77,7 @@ export default function CombosTab() {
|
|||||||
|
|
||||||
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
|
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
|
||||||
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
|
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
|
||||||
Bao gồm
|
Include
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{combo.items.map((item) => (
|
{combo.items.map((item) => (
|
||||||
@@ -99,14 +99,14 @@ export default function CombosTab() {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalCombo(combo)}
|
onClick={() => setModalCombo(combo)}
|
||||||
title="Chỉnh sửa"
|
title="Edit"
|
||||||
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:border-(--color-primary-light) hover:text-(--color-primary)"
|
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:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-pen text-xs"></i>
|
<i className="fa-solid fa-pen text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(combo)}
|
onClick={() => setDeleteTarget(combo)}
|
||||||
title="Xóa"
|
title="Delete"
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-trash text-xs"></i>
|
<i className="fa-solid fa-trash text-xs"></i>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export default function DeleteConfirm({
|
|||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
|
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-foreground text-base font-bold">Xóa "{name}"?</h3>
|
<h3 className="text-foreground text-base font-bold">Delete "{name}"?</h3>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Hành động này không thể hoàn tác.
|
This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -27,13 +27,13 @@ export default function DeleteConfirm({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||||
>
|
>
|
||||||
Hủy
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-red-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 active:scale-95"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-red-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 active:scale-95"
|
||||||
>
|
>
|
||||||
Xóa
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import {
|
||||||
|
addMenuItem,
|
||||||
|
deleteMenuItem,
|
||||||
|
gqlFetch,
|
||||||
|
getMenuItemsByEatery,
|
||||||
|
updateMenuItem,
|
||||||
|
} from "@/lib/graphql";
|
||||||
|
import type { MenuItem } from "@/lib/types";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Eatery {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number) {
|
||||||
|
return price.toLocaleString("en-US") + "₫";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Add state
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editItem, setEditItem] = useState<MenuItem | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editPrice, setEditPrice] = useState("");
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = 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(
|
||||||
|
(id: string) => getMenuItemsByEatery(id),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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("Cannot load menu data. Please check the backend connection.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
}, [loadEateryId, loadMenuItems]);
|
||||||
|
|
||||||
|
// --- Add ---
|
||||||
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim() || !newPrice) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const added = await addMenuItem({
|
||||||
|
name: newName.trim(),
|
||||||
|
price: parseFloat(newPrice),
|
||||||
|
});
|
||||||
|
if (added) setMenuItems((prev) => [...prev, added]);
|
||||||
|
setNewName("");
|
||||||
|
setNewPrice("");
|
||||||
|
setShowForm(false);
|
||||||
|
} catch {
|
||||||
|
setSubmitError("Cannot add dish. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSubmitError(null);
|
||||||
|
setNewName("");
|
||||||
|
setNewPrice("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Edit ---
|
||||||
|
const openEdit = (item: MenuItem) => {
|
||||||
|
setEditItem(item);
|
||||||
|
setEditName(item.name);
|
||||||
|
setEditPrice(String(item.price));
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
setEditItem(null);
|
||||||
|
setEditName("");
|
||||||
|
setEditPrice("");
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editItem || !editName.trim() || !editPrice) return;
|
||||||
|
setEditSubmitting(true);
|
||||||
|
setEditError(null);
|
||||||
|
try {
|
||||||
|
const updated = await updateMenuItem({
|
||||||
|
id: editItem.id,
|
||||||
|
name: editName.trim(),
|
||||||
|
price: parseFloat(editPrice),
|
||||||
|
});
|
||||||
|
if (updated) {
|
||||||
|
setMenuItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === updated.id ? updated : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
closeEdit();
|
||||||
|
} catch {
|
||||||
|
setEditError("Cannot update dish. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
setDeletingId(id);
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
try {
|
||||||
|
const ok = await deleteMenuItem(id);
|
||||||
|
if (ok) setMenuItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
} catch {
|
||||||
|
// silently fail — row stays; user can retry
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
Loading 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">Your eatery has not been initialized in the system.</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> items on the 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">Add new dish</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)">
|
||||||
|
Dish name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
|
Price
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-(--color-border-light)">
|
||||||
|
{menuItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="py-12 text-center text-(--color-text-muted)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
|
||||||
|
There are no items on the menu yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
menuItems.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="transition-colors hover:bg-background"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{deletingId === item.id ? (
|
||||||
|
<i className="fa-solid fa-spinner animate-spin text-(--color-text-muted)"></i>
|
||||||
|
) : confirmDeleteId === item.id ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-xs text-(--color-text-muted)">
|
||||||
|
Are you sure?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
className="cursor-pointer rounded-lg border-none bg-red-500 px-2 py-1 text-xs font-semibold text-white transition hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
|
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-2 py-1 text-xs font-medium text-(--color-text-secondary) transition hover:bg-background"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1.5 text-xs font-medium text-(--color-text-muted) transition hover:bg-background hover:text-(--color-primary)"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-pen-to-square"></i>
|
||||||
|
<span className="hidden lg:inline">Edit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteId(item.id)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1.5 text-xs font-medium text-(--color-text-muted) transition hover:bg-red-50 hover:text-red-500"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-trash"></i>
|
||||||
|
<span className="hidden lg:inline">Delete</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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">Add new dish</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)">
|
||||||
|
Dish name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Enter dish name..."
|
||||||
|
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)">
|
||||||
|
Price (VND) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPrice}
|
||||||
|
onChange={(e) => setNewPrice(e.target.value)}
|
||||||
|
placeholder="Enter price..."
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</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>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add more dishes"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Item Modal */}
|
||||||
|
{editItem && (
|
||||||
|
<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">Edit dish</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeEdit}
|
||||||
|
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={handleEdit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
|
Dish name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
placeholder="Enter dish name..."
|
||||||
|
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)">
|
||||||
|
Price (VND) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editPrice}
|
||||||
|
onChange={(e) => setEditPrice(e.target.value)}
|
||||||
|
placeholder="Enter price..."
|
||||||
|
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>
|
||||||
|
|
||||||
|
{editError && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
<i className="fa-solid fa-circle-exclamation mr-1"></i>
|
||||||
|
{editError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeEdit}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={editSubmitting}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{editSubmitting ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,11 +41,11 @@ export default function ProductModal({
|
|||||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||||
<h2 className="text-foreground text-lg font-bold">
|
<h2 className="text-foreground text-lg font-bold">
|
||||||
{isEdit ? "Chỉnh sửa món" : "Thêm món mới"}
|
{isEdit ? "Edit Item" : "Add New Item"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Đóng"
|
title="Close"
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-xmark"></i>
|
<i className="fa-solid fa-xmark"></i>
|
||||||
@@ -55,7 +55,7 @@ export default function ProductModal({
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Tên món <span className="text-red-500">*</span>
|
Item Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -63,18 +63,18 @@ export default function ProductModal({
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
placeholder="Ví dụ: Cà Phê Đen"
|
placeholder="e.g. Black Coffee"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Danh mục <span className="text-red-500">*</span>
|
Category <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
title="Chọn danh mục"
|
title="Select category"
|
||||||
value={form.category}
|
value={form.category}
|
||||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -88,7 +88,7 @@ export default function ProductModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Giá (đ) <span className="text-red-500">*</span>
|
Price (₫) <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -107,7 +107,7 @@ export default function ProductModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||||
Mô tả
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -116,19 +116,19 @@ export default function ProductModal({
|
|||||||
setForm({ ...form, description: e.target.value })
|
setForm({ ...form, description: e.target.value })
|
||||||
}
|
}
|
||||||
className={`${inputCls} resize-none`}
|
className={`${inputCls} resize-none`}
|
||||||
placeholder="Mô tả ngắn về món..."
|
placeholder="Short description of the item..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-foreground text-sm font-medium">Trạng thái</p>
|
<p className="text-foreground text-sm font-medium">Status</p>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{form.available ? "Còn hàng" : "Tạm hết"}
|
{form.available ? "In Stock" : "Temporarily Out"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
title="Chuyển trạng thái"
|
title="Toggle status"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm({ ...form, available: !form.available })}
|
onClick={() => setForm({ ...form, available: !form.available })}
|
||||||
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
||||||
@@ -149,13 +149,13 @@ export default function ProductModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||||
>
|
>
|
||||||
Hủy
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||||
>
|
>
|
||||||
{isEdit ? "Lưu thay đổi" : "Thêm món"}
|
{isEdit ? "Save Changes" : "Add Item"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ProductModal from "./ProductModal";
|
|||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
function formatPrice(price: number) {
|
function formatPrice(price: number) {
|
||||||
return price.toLocaleString("vi-VN") + "đ";
|
return price.toLocaleString("en-US") + "₫";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductsTab() {
|
export default function ProductsTab() {
|
||||||
@@ -59,12 +59,12 @@ export default function ProductsTab() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Tìm kiếm món..."
|
placeholder="Search dishes..."
|
||||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
title="Xóa tìm kiếm"
|
title="Clear search"
|
||||||
onClick={() => setSearch("")}
|
onClick={() => setSearch("")}
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
|
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
@@ -77,9 +77,9 @@ export default function ProductsTab() {
|
|||||||
value={filterCategory}
|
value={filterCategory}
|
||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||||
title="Lọc theo danh mục"
|
title="Filter by category"
|
||||||
>
|
>
|
||||||
<option value="all">Tất cả danh mục</option>
|
<option value="all">All categories</option>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<option key={cat.id} value={cat.id}>
|
<option key={cat.id} value={cat.id}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
@@ -95,26 +95,26 @@ export default function ProductsTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||||
title="Lọc theo trạng thái"
|
title="Filter by status"
|
||||||
>
|
>
|
||||||
<option value="all">Tất cả trạng thái</option>
|
<option value="all">All states</option>
|
||||||
<option value="available">Còn hàng</option>
|
<option value="available">In stock</option>
|
||||||
<option value="unavailable">Tạm hết</option>
|
<option value="unavailable">Temporarily out of stock</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
title="Thêm món"
|
title="Add dish"
|
||||||
onClick={() => setModalProduct("new")}
|
onClick={() => setModalProduct("new")}
|
||||||
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"
|
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>
|
<i className="fa-solid fa-plus"></i>
|
||||||
<span className="hidden sm:inline">Thêm món</span>
|
<span className="hidden sm:inline">Add new dish</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "}
|
Showing <strong className="text-foreground">{filtered.length}</strong>{" "}
|
||||||
/ {products.length} món
|
/ {products.length} items
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@@ -123,19 +123,19 @@ export default function ProductsTab() {
|
|||||||
<thead className="bg-background">
|
<thead className="bg-background">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
Tên món
|
Item Name
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
Danh mục
|
Category
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
Giá
|
Price
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
||||||
Trạng thái
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
||||||
Thao tác
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -147,7 +147,7 @@ export default function ProductsTab() {
|
|||||||
className="py-12 text-center text-(--color-text-muted)"
|
className="py-12 text-center text-(--color-text-muted)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i>
|
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i>
|
||||||
Không tìm thấy món nào
|
<span className="ml-3">No items found</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -180,7 +180,7 @@ export default function ProductsTab() {
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleProductAvailability(p.id)}
|
onClick={() => toggleProductAvailability(p.id)}
|
||||||
title="Nhấn để đổi trạng thái"
|
title="Click to toggle status"
|
||||||
className="cursor-pointer border-none bg-transparent"
|
className="cursor-pointer border-none bg-transparent"
|
||||||
>
|
>
|
||||||
<StatusBadge available={p.available ?? true} />
|
<StatusBadge available={p.available ?? true} />
|
||||||
@@ -190,14 +190,14 @@ export default function ProductsTab() {
|
|||||||
<div className="flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalProduct(p)}
|
onClick={() => setModalProduct(p)}
|
||||||
title="Chỉnh sửa"
|
title="Edit"
|
||||||
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:border-(--color-primary-light) hover:text-(--color-primary)"
|
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:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-pen text-xs"></i>
|
<i className="fa-solid fa-pen text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(p)}
|
onClick={() => setDeleteTarget(p)}
|
||||||
title="Xóa"
|
title="Delete"
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-trash text-xs"></i>
|
<i className="fa-solid fa-trash text-xs"></i>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
|||||||
available ? "bg-emerald-500" : "bg-amber-500"
|
available ? "bg-emerald-500" : "bg-amber-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{available ? "Còn hàng" : "Tạm hết"}
|
{available ? "In stock" : "Out of stock"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { default as ComboModal } from "./ComboModal";
|
|||||||
export { default as ProductsTab } from "./ProductsTab";
|
export { default as ProductsTab } from "./ProductsTab";
|
||||||
export { default as CategoriesTab } from "./CategoriesTab";
|
export { default as CategoriesTab } from "./CategoriesTab";
|
||||||
export { default as CombosTab } from "./CombosTab";
|
export { default as CombosTab } from "./CombosTab";
|
||||||
|
export { default as MenuItemsTab } from "./MenuItemsTab";
|
||||||
export type {
|
export type {
|
||||||
ProductModalProps,
|
ProductModalProps,
|
||||||
CategoryModalProps,
|
CategoryModalProps,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface ReviewModalProps {
|
export interface ReviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
eateryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmModalProps {
|
export interface ConfirmModalProps {
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
||||||
|
import { createReview, hasAuthCookie } from "@/lib/api/review";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { ReviewModalProps } from "./Modal.types";
|
import type { ReviewModalProps } from "./Modal.types";
|
||||||
|
|
||||||
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
export default function ReviewModal({ isOpen, onClose, eateryId }: ReviewModalProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [hovered, setHovered] = useState(0);
|
const [hovered, setHovered] = useState(0);
|
||||||
const [review, setReview] = useState("");
|
const [review, setReview] = useState("");
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const isAuthenticated = hasAuthCookie() || user?.role === "customer";
|
||||||
setSubmitted(true);
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setError("Please log in as a customer to submit a review");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!eateryId) {
|
||||||
|
setError("No eatery selected — please go back to the feed and select a shop first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createReview({ eateryId, rating, comment: review });
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Reset state when closing
|
|
||||||
setRating(0);
|
setRating(0);
|
||||||
setHovered(0);
|
setHovered(0);
|
||||||
setReview("");
|
setReview("");
|
||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
|
setError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,13 +73,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
||||||
</div>
|
</div>
|
||||||
<Heading level={2} id="review-modal-title">
|
<Heading level={2} id="review-modal-title">
|
||||||
Cảm ơn quý khách
|
Thank you
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text variant="body2" className="mt-2">
|
<Text variant="body2" className="mt-2">
|
||||||
Chúng tôi trân trọng đánh giá của bạn!
|
We appreciate your feedback!
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
<Button onClick={handleClose} variant="primary" className="mt-4">
|
||||||
Đóng
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -65,21 +89,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
id="review-modal-title"
|
id="review-modal-title"
|
||||||
className="text-foreground mb-1 text-xl font-bold"
|
className="text-foreground mb-1 text-xl font-bold"
|
||||||
>
|
>
|
||||||
Đánh giá của bạn
|
Your Review
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-5 text-sm text-(--color-text-muted)">
|
<p className="mb-5 text-sm text-(--color-text-muted)">
|
||||||
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
|
Tell us about your experience today
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Star rating */}
|
{/* Star rating */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
||||||
Mức độ hài lòng
|
Satisfaction level
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Xếp hạng sao"
|
aria-label="Star rating"
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((star) => {
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
const isActive = star <= (hovered || rating);
|
const isActive = star <= (hovered || rating);
|
||||||
@@ -90,7 +114,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
onClick={() => setRating(star)}
|
onClick={() => setRating(star)}
|
||||||
onMouseEnter={() => setHovered(star)}
|
onMouseEnter={() => setHovered(star)}
|
||||||
onMouseLeave={() => setHovered(0)}
|
onMouseLeave={() => setHovered(0)}
|
||||||
aria-label={`${star} sao`}
|
aria-label={`${star} star`}
|
||||||
aria-pressed={rating === star}
|
aria-pressed={rating === star}
|
||||||
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
||||||
>
|
>
|
||||||
@@ -108,7 +132,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
{rating > 0 && (
|
{rating > 0 && (
|
||||||
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
||||||
{
|
{
|
||||||
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
|
["", "Very poor", "Poor", "Average", "Good", "Excellent"][
|
||||||
rating
|
rating
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -120,14 +144,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="review-text"
|
id="review-text"
|
||||||
label="Nhận xét (tùy chọn)"
|
label="Comment (optional)"
|
||||||
value={review}
|
value={review}
|
||||||
onChange={(e) => setReview(e.target.value)}
|
onChange={(e) => setReview(e.target.value)}
|
||||||
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
|
placeholder="Share your thoughts on the drinks, service..."
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer buttons */}
|
{/* Footer buttons */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -137,16 +168,16 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
icon="fa-arrow-left"
|
icon="fa-arrow-left"
|
||||||
>
|
>
|
||||||
Quay lại
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={rating === 0}
|
disabled={rating === 0 || isLoading}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
icon="fa-check"
|
icon={isLoading ? "fa-spinner fa-spin" : "fa-check"}
|
||||||
>
|
>
|
||||||
Xác nhận
|
{isLoading ? "Submitting..." : "Confirm"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ export default function CategorySidebar({
|
|||||||
className={`text-xs font-bold tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${isOpen ? "block" : "hidden"} xl:block`}
|
className={`text-xs font-bold tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${isOpen ? "block" : "hidden"} xl:block`}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
|
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
|
||||||
Thực Đơn
|
Menu
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
|
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
title={isOpen ? "Collapse menu" : "Expand menu"}
|
||||||
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
aria-label={isOpen ? "Collapse menu" : "Expand menu"}
|
||||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
|
|||||||
@@ -2,19 +2,66 @@
|
|||||||
|
|
||||||
import { ProductCard } from "@/components/molecules/cards";
|
import { ProductCard } from "@/components/molecules/cards";
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||||
|
import { getMenuItemsByEatery } from "@/lib/graphql";
|
||||||
import { useMenu } from "@/lib/menu-context";
|
import { useMenu } from "@/lib/menu-context";
|
||||||
|
import type { Product } from "@/lib/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { ProductGridProps } from "./ProductGrid.types";
|
import type { ProductGridProps } from "./ProductGrid.types";
|
||||||
|
|
||||||
|
function generateDescription(name: string): string {
|
||||||
|
return `${name} thơm ngon, hấp dẫn — hoàn hảo cho một buổi thư giãn.`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductGrid({
|
export default function ProductGrid({
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isSidebarOpen = false,
|
isSidebarOpen = false,
|
||||||
}: ProductGridProps) {
|
}: ProductGridProps) {
|
||||||
const { activeCategory, setActiveCategory } = useMenu();
|
const { activeCategory, setActiveCategory } = useMenu();
|
||||||
const { addToCart } = useCart();
|
const { addToCart, eateryId } = useCart();
|
||||||
|
|
||||||
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eateryId) {
|
||||||
|
setProducts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
getMenuItemsByEatery(eateryId)
|
||||||
|
.then((items) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProducts(
|
||||||
|
items.map((item, index) => ({
|
||||||
|
id: index,
|
||||||
|
menuItemId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
price: item.price,
|
||||||
|
image: "/imgs/products/placeholder.jpg",
|
||||||
|
category: "all",
|
||||||
|
description: generateDescription(item.name),
|
||||||
|
available: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setProducts([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [eateryId]);
|
||||||
|
|
||||||
|
const filteredProducts = products.filter((p) => {
|
||||||
const isAvailable = p.available !== false;
|
const isAvailable = p.available !== false;
|
||||||
const matchesCategory =
|
const matchesCategory =
|
||||||
activeCategory === "all" || p.category === activeCategory;
|
activeCategory === "all" || p.category === activeCategory;
|
||||||
@@ -25,9 +72,6 @@ export default function ProductGrid({
|
|||||||
return isAvailable && matchesCategory && matchesSearch;
|
return isAvailable && matchesCategory && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeCategoryLabel =
|
|
||||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
|
||||||
|
|
||||||
const gridCols = isSidebarOpen
|
const gridCols = isSidebarOpen
|
||||||
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
||||||
@@ -59,8 +103,20 @@ export default function ProductGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loading skeleton ── */}
|
||||||
|
{loading && (
|
||||||
|
<div className={`grid gap-4 ${gridCols}`}>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse rounded-2xl bg-(--color-border-light) h-64"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Product grid ── */}
|
{/* ── Product grid ── */}
|
||||||
{filteredProducts.length > 0 ? (
|
{!loading && filteredProducts.length > 0 && (
|
||||||
<div className={`grid gap-4 ${gridCols}`}>
|
<div className={`grid gap-4 ${gridCols}`}>
|
||||||
{filteredProducts.map((product) => (
|
{filteredProducts.map((product) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
@@ -74,14 +130,16 @@ export default function ProductGrid({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
/* Empty state */
|
|
||||||
|
{/* ── Empty state ── */}
|
||||||
|
{!loading && filteredProducts.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
|
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
|
||||||
<p className="text-base font-medium">
|
<p className="text-base font-medium">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `Không tìm thấy món nào cho "${searchQuery}"`
|
? `No items found for "${searchQuery}"`
|
||||||
: "Chưa có món trong danh mục này"}
|
: "No items in this category yet"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
|
|||||||
|
|
||||||
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
function formatDateISO(d: Date): string {
|
function formatDateISO(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
@@ -26,18 +26,18 @@ function isToday(d: Date): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1",
|
"January",
|
||||||
"Tháng 2",
|
"February",
|
||||||
"Tháng 3",
|
"March",
|
||||||
"Tháng 4",
|
"April",
|
||||||
"Tháng 5",
|
"May",
|
||||||
"Tháng 6",
|
"June",
|
||||||
"Tháng 7",
|
"July",
|
||||||
"Tháng 8",
|
"August",
|
||||||
"Tháng 9",
|
"September",
|
||||||
"Tháng 10",
|
"October",
|
||||||
"Tháng 11",
|
"November",
|
||||||
"Tháng 12",
|
"December",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileShiftView({
|
export default function MobileShiftView({
|
||||||
@@ -94,7 +94,7 @@ export default function MobileShiftView({
|
|||||||
{/* Month navigation */}
|
{/* Month navigation */}
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
title="Trở lại tháng trước"
|
title="Previous month"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToPrevMonth}
|
onClick={goToPrevMonth}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
@@ -105,7 +105,7 @@ export default function MobileShiftView({
|
|||||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
title="Trở lại tháng sau"
|
title="Next month"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToNextMonth}
|
onClick={goToNextMonth}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
@@ -173,22 +173,22 @@ export default function MobileShiftView({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
Còn trống
|
Available
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span>
|
<span className="text-[10px] text-(--color-text-muted)">Registered</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
Nghỉ phép
|
On leave
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">Vắng</span>
|
<span className="text-[10px] text-(--color-text-muted)">Absent</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +199,7 @@ export default function MobileShiftView({
|
|||||||
{dayOfWeek}, {selectedDateObj.getDate()}/
|
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||||
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||||
({selectedShifts.length} ca)
|
({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export default function MobileShiftView({
|
|||||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||||
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Không có ca làm trong ngày này
|
No shifts for this day
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
function formatDateISO(d: Date): string {
|
function formatDateISO(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
@@ -125,7 +125,7 @@ export default function MonthlyCalendar({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||||
<span className="text-[10px] text-blue-600">
|
<span className="text-[10px] text-blue-600">
|
||||||
{summary.available} trống
|
{summary.available} available
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -133,7 +133,7 @@ export default function MonthlyCalendar({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
|
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
|
||||||
<span className="text-[10px] text-blue-800">
|
<span className="text-[10px] text-blue-800">
|
||||||
{summary.registered} đã ĐK
|
{summary.registered} registered
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -141,7 +141,7 @@ export default function MonthlyCalendar({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||||
<span className="text-[10px] text-purple-600">
|
<span className="text-[10px] text-purple-600">
|
||||||
{summary.leave} nghỉ
|
{summary.leave} on leave
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -149,7 +149,7 @@ export default function MonthlyCalendar({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||||
<span className="text-[10px] text-red-600">
|
<span className="text-[10px] text-red-600">
|
||||||
{summary.absent} vắng
|
{summary.absent} absent
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function ShiftCreateModal({
|
|||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!date || !startTime || !endTime) {
|
if (!date || !startTime || !endTime) {
|
||||||
setError("Vui lòng điền đầy đủ thông tin.");
|
setError("Please fill in all required fields.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export default function ShiftCreateModal({
|
|||||||
const endMinutes = eh * 60 + em;
|
const endMinutes = eh * 60 + em;
|
||||||
|
|
||||||
if (endMinutes <= startMinutes) {
|
if (endMinutes <= startMinutes) {
|
||||||
setError("Giờ kết thúc phải sau giờ bắt đầu.");
|
setError("End time must be after start time.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +77,10 @@ export default function ShiftCreateModal({
|
|||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-foreground text-base font-bold">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Tạo ca làm mới
|
Create New Shift
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
Thêm khung giờ ca làm cho nhân viên
|
Add a shift time slot for staff
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -98,7 +98,7 @@ export default function ShiftCreateModal({
|
|||||||
{/* Date */}
|
{/* Date */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Ngày
|
Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="Date"
|
title="Date"
|
||||||
@@ -113,7 +113,7 @@ export default function ShiftCreateModal({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Giờ bắt đầu
|
Start Time
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="Start Time"
|
title="Start Time"
|
||||||
@@ -125,7 +125,7 @@ export default function ShiftCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Giờ kết thúc
|
End Time
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="End Time"
|
title="End Time"
|
||||||
@@ -140,7 +140,7 @@ export default function ShiftCreateModal({
|
|||||||
{/* Department */}
|
{/* Department */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Bộ phận
|
Department
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
title="Department"
|
title="Department"
|
||||||
@@ -160,7 +160,7 @@ export default function ShiftCreateModal({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Số nhân viên tối đa
|
Max Staff
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="Max Staff"
|
title="Max Staff"
|
||||||
@@ -174,7 +174,7 @@ export default function ShiftCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Lương ca (VND)
|
Wage (VND)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
title="Wage"
|
title="Wage"
|
||||||
@@ -203,14 +203,14 @@ export default function ShiftCreateModal({
|
|||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-plus mr-2"></i>
|
<i className="fa-solid fa-plus mr-2"></i>
|
||||||
Tạo ca làm
|
Create Shift
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Hủy
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export default function ShiftDetailModal({
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
const result = registerShift(shift.id, user.id, user.name);
|
const result = registerShift(shift.id, user.id, user.name);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess("Đăng ký ca thành công!");
|
setSuccess("Shift registered successfully!");
|
||||||
setTimeout(onClose, 1200);
|
setTimeout(onClose, 1200);
|
||||||
} else {
|
} else {
|
||||||
setError(result.error ?? "Có lỗi xảy ra.");
|
setError(result.error ?? "An error occurred.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@ export default function ShiftDetailModal({
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
unregisterShift(shift.id, user.id);
|
unregisterShift(shift.id, user.id);
|
||||||
setSuccess("Đã hủy đăng ký ca.");
|
setSuccess("Shift registration cancelled.");
|
||||||
setTimeout(onClose, 1200);
|
setTimeout(onClose, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManagerUnregister = (staffId: number) => {
|
const handleManagerUnregister = (staffId: number) => {
|
||||||
unregisterShift(shift.id, staffId);
|
unregisterShift(shift.id, staffId);
|
||||||
setSuccess("Đã xóa nhân viên khỏi ca.");
|
setSuccess("Staff removed from shift.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -58,10 +58,10 @@ export default function ShiftDetailModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel = {
|
const statusLabel = {
|
||||||
available: "Còn trống",
|
available: "Available",
|
||||||
registered: "Đã đăng ký",
|
registered: "Registered",
|
||||||
approved_leave: "Nghỉ phép",
|
approved_leave: "On Leave",
|
||||||
absent: "Vắng mặt",
|
absent: "Absent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColor = {
|
const statusColor = {
|
||||||
@@ -84,7 +84,7 @@ export default function ShiftDetailModal({
|
|||||||
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-foreground text-base font-bold">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Chi tiết ca làm
|
Shift Details
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,11 +114,11 @@ export default function ShiftDetailModal({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
Ngày
|
Date
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground mt-1 text-sm font-bold">
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
||||||
"vi-VN",
|
"en-US",
|
||||||
{
|
{
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -130,7 +130,7 @@ export default function ShiftDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
Giờ làm
|
Work Hours
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground mt-1 text-sm font-bold">
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.startTime} – {shift.endTime}
|
{shift.startTime} – {shift.endTime}
|
||||||
@@ -138,18 +138,18 @@ export default function ShiftDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
Thời lượng
|
Duration
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground mt-1 text-sm font-bold">
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.durationHours} giờ
|
{shift.durationHours} hrs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
Lương ca
|
Shift Wage
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
||||||
{shift.wage.toLocaleString("vi-VN")} VND
|
{shift.wage.toLocaleString("en-US")} VND
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,12 +157,12 @@ export default function ShiftDetailModal({
|
|||||||
{/* Registered staff */}
|
{/* Registered staff */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
Registered Staff ({shift.registeredStaff.length}/
|
||||||
{shift.maxStaff})
|
{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
{shift.registeredStaff.length === 0 ? (
|
{shift.registeredStaff.length === 0 ? (
|
||||||
<p className="text-xs text-(--color-text-muted) italic">
|
<p className="text-xs text-(--color-text-muted) italic">
|
||||||
Chưa có ai đăng ký
|
No one registered yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -186,7 +186,7 @@ export default function ShiftDetailModal({
|
|||||||
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50"
|
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-user-minus mr-1"></i>
|
<i className="fa-solid fa-user-minus mr-1"></i>
|
||||||
Xóa
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +222,7 @@ export default function ShiftDetailModal({
|
|||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||||
Đăng ký ca
|
Register Shift
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isRegistered && (
|
{isRegistered && (
|
||||||
@@ -232,7 +232,7 @@ export default function ShiftDetailModal({
|
|||||||
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-calendar-minus mr-2"></i>
|
<i className="fa-solid fa-calendar-minus mr-2"></i>
|
||||||
Hủy đăng ký
|
Cancel Registration
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isManager && (
|
{isManager && (
|
||||||
@@ -242,7 +242,7 @@ export default function ShiftDetailModal({
|
|||||||
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-trash mr-2"></i>
|
<i className="fa-solid fa-trash mr-2"></i>
|
||||||
Xóa ca
|
Delete Shift
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -250,7 +250,7 @@ export default function ShiftDetailModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Đóng
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const MONTH_NAMES_EN = [
|
|||||||
"December",
|
"December",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
function formatDateShort(d: Date): string {
|
function formatDateShort(d: Date): string {
|
||||||
@@ -94,7 +94,7 @@ export default function WeeklySchedule({
|
|||||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
title="Tuần trước"
|
title="Previous week"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToPrevWeek}
|
onClick={goToPrevWeek}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
@@ -105,7 +105,7 @@ export default function WeeklySchedule({
|
|||||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
title="Tuần sau"
|
title="Next week"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToNextWeek}
|
onClick={goToNextWeek}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
@@ -192,7 +192,7 @@ export default function WeeklySchedule({
|
|||||||
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-plus mr-1"></i>
|
<i className="fa-solid fa-plus mr-1"></i>
|
||||||
Thêm ca
|
Add shift
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@ export default function WeeklySchedule({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||||
Bộ phận
|
Department
|
||||||
</th>
|
</th>
|
||||||
{weekDates.map((date, i) => (
|
{weekDates.map((date, i) => (
|
||||||
<th
|
<th
|
||||||
@@ -273,7 +273,7 @@ export default function WeeklySchedule({
|
|||||||
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-plus mr-1"></i>
|
<i className="fa-solid fa-plus mr-1"></i>
|
||||||
Thêm ca
|
Add shift
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function ShopGrid({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||||
<p className="text-base font-medium">Không tìm thấy quán nào phù hợp</p>
|
<p className="text-base font-medium">No shops found matching your search</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ export default function ShopGrid({
|
|||||||
<ShopCard
|
<ShopCard
|
||||||
key={shop.id}
|
key={shop.id}
|
||||||
id={shop.id}
|
id={shop.id}
|
||||||
|
eateryId={shop.eateryId}
|
||||||
name={shop.name}
|
name={shop.name}
|
||||||
address={shop.address}
|
address={shop.address}
|
||||||
image={shop.image}
|
image={shop.image}
|
||||||
|
|||||||
@@ -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 { MainLayout } from "./main-layout";
|
||||||
export type { MainLayoutProps } from "./main-layout";
|
export type { MainLayoutProps } from "./main-layout";
|
||||||
|
|
||||||
// Auth Layout
|
|
||||||
export { AuthLayout } from "./auth-layout";
|
|
||||||
export type { AuthLayoutProps } from "./auth-layout";
|
|
||||||
|
|
||||||
// Feed Layout
|
// Feed Layout
|
||||||
export { FeedLayout } from "./feed-layout";
|
export { FeedLayout } from "./feed-layout";
|
||||||
export type { FeedLayoutProps } from "./feed-layout";
|
export type { FeedLayoutProps } from "./feed-layout";
|
||||||
|
|||||||
@@ -13,26 +13,26 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
|
|||||||
* Redirects non-managers away; shows loading state while auth resolves.
|
* Redirects non-managers away; shows loading state while auth resolves.
|
||||||
*/
|
*/
|
||||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||||
const { user } = useAuth();
|
const { user, isInitialized } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user !== null && user.role !== "manager") {
|
if (isInitialized && user !== null && user.role !== "manager") {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [user, router]);
|
}, [user, isInitialized, router]);
|
||||||
|
|
||||||
if (user === null) {
|
if (!isInitialized || user === null) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
<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)">
|
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
||||||
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
|
<p className="text-sm">Checking access...</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-sm font-medium text-(--color-primary) hover:underline"
|
className="text-sm font-medium text-(--color-primary) hover:underline"
|
||||||
>
|
>
|
||||||
Đăng nhập nếu chưa có tài khoản
|
Sign in if you don't have an account
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ export default function StaffLayout({ children }: StaffLayoutProps) {
|
|||||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
<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)">
|
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
||||||
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
|
<p className="text-sm">Checking access...</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-sm font-medium text-(--color-primary) hover:underline"
|
className="text-sm font-medium text-(--color-primary) hover:underline"
|
||||||
>
|
>
|
||||||
Đăng nhập nếu chưa có tài khoản
|
Sign in if you don't have an account
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -48,7 +48,7 @@ export default function Footer() {
|
|||||||
<ul className="flex flex-col gap-2 text-sm opacity-80">
|
<ul className="flex flex-col gap-2 text-sm opacity-80">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
|
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||||
<span>Địa chỉ: {SHOP_INFO.address}</span>
|
<span>Address: {SHOP_INFO.address}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
|
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||||
@@ -56,7 +56,7 @@ export default function Footer() {
|
|||||||
href={`tel:${SHOP_INFO.phone}`}
|
href={`tel:${SHOP_INFO.phone}`}
|
||||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||||
>
|
>
|
||||||
Số điện thoại: {SHOP_INFO.phone}
|
Phone: {SHOP_INFO.phone}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
@@ -83,7 +83,7 @@ export default function Footer() {
|
|||||||
{/* ── 2. Social links ── */}
|
{/* ── 2. Social links ── */}
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||||
Kết nối
|
Follow Us
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
<li>
|
<li>
|
||||||
@@ -129,20 +129,20 @@ export default function Footer() {
|
|||||||
{/* ── 3. WiFi card ── */}
|
{/* ── 3. WiFi card ── */}
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||||
WiFi Miễn Phí
|
Free WiFi
|
||||||
</h3>
|
</h3>
|
||||||
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
|
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
|
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
Kết nối miễn phí
|
Connect for free
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Stacked label + value rows — no overflow risk */}
|
{/* Stacked label + value rows — no overflow risk */}
|
||||||
<div className="flex flex-col gap-3 text-sm">
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||||
Tên mạng
|
Network name
|
||||||
</span>
|
</span>
|
||||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
|
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
|
||||||
{SHOP_INFO.wifi.name}
|
{SHOP_INFO.wifi.name}
|
||||||
@@ -150,7 +150,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||||
Mật khẩu
|
Password
|
||||||
</span>
|
</span>
|
||||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
|
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
|
||||||
{SHOP_INFO.wifi.password}
|
{SHOP_INFO.wifi.password}
|
||||||
@@ -170,9 +170,9 @@ export default function Footer() {
|
|||||||
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
|
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
Được vận hành{" "}
|
Powered by{" "}
|
||||||
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
||||||
bằng Drinkool
|
Drinkool
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -72,11 +72,11 @@ export default function Header() {
|
|||||||
/* Guest: sign-in button */
|
/* Guest: sign-in button */
|
||||||
<button
|
<button
|
||||||
onClick={handleAuthClick}
|
onClick={handleAuthClick}
|
||||||
title="Đăng nhập"
|
title="Sign in"
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-right-to-bracket"></i>
|
<i className="fa-solid fa-right-to-bracket"></i>
|
||||||
<span className="hidden sm:inline">Đăng nhập</span>
|
<span className="hidden sm:inline">Sign in</span>
|
||||||
</button>
|
</button>
|
||||||
) : user.role === "manager" ? (
|
) : user.role === "manager" ? (
|
||||||
/* Manager: dashboard link + logout */
|
/* Manager: dashboard link + logout */
|
||||||
@@ -90,8 +90,8 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleAuthClick}
|
onClick={handleAuthClick}
|
||||||
title="Đăng xuất"
|
title="Sign out"
|
||||||
aria-label="Đăng xuất"
|
aria-label="Sign out"
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
||||||
@@ -105,12 +105,12 @@ export default function Header() {
|
|||||||
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
|
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-calendar-check text-base"></i>
|
<i className="fa-solid fa-calendar-check text-base"></i>
|
||||||
<span className="hidden sm:inline">Ca làm</span>
|
<span className="hidden sm:inline">My Shifts</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleAuthClick}
|
onClick={handleAuthClick}
|
||||||
title="Nhấn để đăng xuất"
|
title="Click to sign out"
|
||||||
aria-label="Đăng xuất"
|
aria-label="Sign out"
|
||||||
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
||||||
>
|
>
|
||||||
{/* Avatar circle */}
|
{/* Avatar circle */}
|
||||||
@@ -124,14 +124,14 @@ export default function Header() {
|
|||||||
/* Customer: phone icon + label */
|
/* Customer: phone icon + label */
|
||||||
<button
|
<button
|
||||||
onClick={handleAuthClick}
|
onClick={handleAuthClick}
|
||||||
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
|
title={`Customer - ${user.phone || ""} - Click to sign out`}
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
|
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
|
||||||
>
|
>
|
||||||
{/* Customer icon */}
|
{/* Customer icon */}
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
|
||||||
<i className="fa-solid fa-user"></i>
|
<i className="fa-solid fa-user"></i>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:inline">Khách hàng</span>
|
<span className="hidden sm:inline">Customer</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
* e.g. 1_500_000 → "1.5 tr", 25_000 → "25 k"
|
* e.g. 1_500_000 → "1.5 tr", 25_000 → "25 k"
|
||||||
*/
|
*/
|
||||||
export function formatCurrency(value: number): string {
|
export function formatCurrency(value: number): string {
|
||||||
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " tỷ";
|
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " B";
|
||||||
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " tr";
|
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " M";
|
||||||
if (value >= 1_000) return (value / 1_000).toFixed(0) + " k";
|
if (value >= 1_000) return (value / 1_000).toFixed(0) + " k";
|
||||||
return value.toLocaleString("vi-VN") + "đ";
|
return value.toLocaleString() + "₫";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +16,7 @@ export function formatCurrency(value: number): string {
|
|||||||
* e.g. 25_000 → "25.000đ"
|
* e.g. 25_000 → "25.000đ"
|
||||||
*/
|
*/
|
||||||
export function formatCurrencyFull(value: number): string {
|
export function formatCurrencyFull(value: number): string {
|
||||||
return value.toLocaleString("vi-VN") + "đ";
|
return value.toLocaleString("en-US") + "₫";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface SendReview {
|
||||||
|
eateryId: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuthCookie(): boolean {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
return document.cookie.split(";").some((c) => c.trim().startsWith("auth="));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReview(input: SendReview): Promise<void> {
|
||||||
|
const res = await fetch("/api/eatery/review", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `Failed to submit review (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-119
@@ -12,95 +12,18 @@ import type { User } from "./types";
|
|||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
login: (username: string, password: string) => Promise<boolean>;
|
setUser: (user: User | null) => void;
|
||||||
|
isInitialized: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
registerPhone: string | null;
|
|
||||||
setRegisterPhone: (phone: string | null) => void;
|
|
||||||
completeRegistration: (phone: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
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 }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||||
if (savedUser) {
|
if (savedUser) {
|
||||||
@@ -110,32 +33,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
console.error("Failed to parse saved user", e);
|
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 {
|
try {
|
||||||
const response = await fetch("/api/customer/login", {
|
const response = await fetch("/api/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ phone: username, password, role }),
|
||||||
},
|
|
||||||
body: JSON.stringify({ phone, password }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const userData = await response.json();
|
const userData = await response.json();
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||||
|
return { ok: true };
|
||||||
return true;
|
|
||||||
} else {
|
} else {
|
||||||
console.error("Đăng nhập thất bại:", response.statusText);
|
console.error("Login failed:", response.status);
|
||||||
return false;
|
return { ok: false, status: response.status };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi kết nối API:", error);
|
console.error("API connection error:", error);
|
||||||
return false;
|
return { ok: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,37 +67,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.removeItem("coffee-shop-user");
|
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 (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
|
setUser,
|
||||||
|
isInitialized,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
registerPhone,
|
|
||||||
setRegisterPhone,
|
|
||||||
completeRegistration,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+110
-8
@@ -1,7 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addItem as addItemGql,
|
||||||
|
createCart as createCartGql,
|
||||||
|
} from "@/lib/graphql/cart";
|
||||||
import type { Product } from "@/lib/types";
|
import type { Product } from "@/lib/types";
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,12 +21,16 @@ export interface CartItem {
|
|||||||
description: string;
|
description: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
menuItemId?: string; // backend UUID for GraphQL sync
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartContextValue {
|
interface CartContextValue {
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
|
eateryId: string | null;
|
||||||
|
backendCartId: string | null;
|
||||||
|
setEateryId: (id: string) => void;
|
||||||
addToCart: (product: Product) => void;
|
addToCart: (product: Product) => void;
|
||||||
increaseQty: (id: number) => void;
|
increaseQty: (id: number) => void;
|
||||||
decreaseQty: (id: number) => void;
|
decreaseQty: (id: number) => void;
|
||||||
@@ -23,29 +39,88 @@ interface CartContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "coffee-shop-cart";
|
const STORAGE_KEY = "coffee-shop-cart";
|
||||||
|
const EATERY_KEY = "coffee-shop-eatery-id";
|
||||||
|
const CART_ID_KEY = "coffee-shop-cart-id";
|
||||||
const CartContext = createContext<CartContextValue | null>(null);
|
const CartContext = createContext<CartContextValue | null>(null);
|
||||||
|
|
||||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [items, setItems] = useState<CartItem[]>([]);
|
const [items, setItems] = useState<CartItem[]>([]);
|
||||||
|
const [eateryId, setEateryIdState] = useState<string | null>(null);
|
||||||
|
const [backendCartId, setBackendCartId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs give async callbacks always-current values without stale closures
|
||||||
|
const backendCartIdRef = useRef<string | null>(null);
|
||||||
|
const eateryIdRef = useRef<string | null>(null);
|
||||||
|
const pendingCreateRef = useRef<Promise<string | null> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return;
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as CartItem[];
|
const parsed = JSON.parse(raw) as CartItem[];
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedEateryId = localStorage.getItem(EATERY_KEY);
|
||||||
|
if (savedEateryId) {
|
||||||
|
setEateryIdState(savedEateryId);
|
||||||
|
eateryIdRef.current = savedEateryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedCartId = localStorage.getItem(CART_ID_KEY);
|
||||||
|
if (savedCartId) {
|
||||||
|
setBackendCartId(savedCartId);
|
||||||
|
backendCartIdRef.current = savedCartId;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
backendCartIdRef.current = backendCartId;
|
||||||
|
}, [backendCartId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eateryIdRef.current = eateryId;
|
||||||
|
}, [eateryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
const persistCartId = useCallback((cartId: string) => {
|
||||||
|
setBackendCartId(cartId);
|
||||||
|
backendCartIdRef.current = cartId;
|
||||||
|
localStorage.setItem(CART_ID_KEY, cartId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Creates backend cart on demand; deduplicates concurrent calls
|
||||||
|
const ensureBackendCartId = useCallback(async (): Promise<string | null> => {
|
||||||
|
if (backendCartIdRef.current) return backendCartIdRef.current;
|
||||||
|
const eid = eateryIdRef.current;
|
||||||
|
if (!eid) return null;
|
||||||
|
if (pendingCreateRef.current) return pendingCreateRef.current;
|
||||||
|
|
||||||
|
pendingCreateRef.current = createCartGql(eid)
|
||||||
|
.then((cartId) => {
|
||||||
|
persistCartId(cartId);
|
||||||
|
pendingCreateRef.current = null;
|
||||||
|
return cartId;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pendingCreateRef.current = null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingCreateRef.current;
|
||||||
|
}, [persistCartId]);
|
||||||
|
|
||||||
const addToCart = (product: Product) => {
|
const addToCart = (product: Product) => {
|
||||||
|
const snapshot = items;
|
||||||
|
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const index = prev.findIndex((i) => i.id === product.id);
|
const index = prev.findIndex((i) => i.id === product.id);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -57,14 +132,21 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
description: product.description,
|
description: product.description,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
menuItemId: product.menuItemId,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (product.menuItemId) {
|
||||||
|
const mid = product.menuItemId;
|
||||||
|
ensureBackendCartId()
|
||||||
|
.then((cartId) => (cartId ? addItemGql(cartId, mid, 1) : undefined))
|
||||||
|
.catch(() => setItems(snapshot));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const increaseQty = (id: number) => {
|
const increaseQty = (id: number) => {
|
||||||
@@ -87,8 +169,24 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setEateryId = (id: string) => {
|
||||||
|
setEateryIdState(id);
|
||||||
|
eateryIdRef.current = id;
|
||||||
|
localStorage.setItem(EATERY_KEY, id);
|
||||||
|
};
|
||||||
|
|
||||||
const removeFromCart = (id: number) => {
|
const removeFromCart = (id: number) => {
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
const item = items.find((i) => i.id === id);
|
||||||
|
const snapshot = items;
|
||||||
|
|
||||||
|
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||||
|
|
||||||
|
if (item?.menuItemId && backendCartIdRef.current) {
|
||||||
|
const cartId = backendCartIdRef.current;
|
||||||
|
addItemGql(cartId, item.menuItemId, -item.quantity).catch(() =>
|
||||||
|
setItems(snapshot),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQuantity = (id: number, quantity: number) => {
|
const setQuantity = (id: number, quantity: number) => {
|
||||||
@@ -122,13 +220,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
items,
|
items,
|
||||||
totalItems,
|
totalItems,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
|
eateryId,
|
||||||
|
backendCartId,
|
||||||
|
setEateryId,
|
||||||
addToCart,
|
addToCart,
|
||||||
increaseQty,
|
increaseQty,
|
||||||
decreaseQty,
|
decreaseQty,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
setQuantity,
|
setQuantity,
|
||||||
}),
|
}),
|
||||||
[items, totalItems, totalPrice],
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[items, totalItems, totalPrice, eateryId, backendCartId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||||
|
|||||||
+101
-112
@@ -15,9 +15,9 @@ import type {
|
|||||||
// ===== SHOP INFORMATION =====
|
// ===== SHOP INFORMATION =====
|
||||||
export const SHOP_INFO: ShopInfo = {
|
export const SHOP_INFO: ShopInfo = {
|
||||||
name: "Coffee Shop",
|
name: "Coffee Shop",
|
||||||
tagline: "Hương vị đậm đà – Khoảnh khắc thư giãn",
|
tagline: "Rich Flavors – Moments of Relaxation",
|
||||||
logo: "/imgs/logo.png",
|
logo: "/imgs/logo.png",
|
||||||
address: "123 Đường Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
|
||||||
phone: "0901 234 567",
|
phone: "0901 234 567",
|
||||||
managerPhone: "0912 345 678",
|
managerPhone: "0912 345 678",
|
||||||
email: "contact@coffeeshop.vn",
|
email: "contact@coffeeshop.vn",
|
||||||
@@ -25,7 +25,7 @@ export const SHOP_INFO: ShopInfo = {
|
|||||||
name: "CoffeeShop_Free",
|
name: "CoffeeShop_Free",
|
||||||
password: "coffee2024",
|
password: "coffee2024",
|
||||||
},
|
},
|
||||||
openHours: "07:00 – 22:00 (Thứ 2 – Chủ nhật)",
|
openHours: "07:00 – 22:00 (Monday – Sunday)",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== SOCIAL LINKS =====
|
// ===== SOCIAL LINKS =====
|
||||||
@@ -38,15 +38,15 @@ export const SOCIAL_LINKS: SocialLinks = {
|
|||||||
// ===== MENU CATEGORIES =====
|
// ===== MENU CATEGORIES =====
|
||||||
// Each category has a unique FontAwesome icon representing the item type
|
// Each category has a unique FontAwesome icon representing the item type
|
||||||
export const MENU_CATEGORIES: MenuCategory[] = [
|
export const MENU_CATEGORIES: MenuCategory[] = [
|
||||||
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" },
|
{ id: "all", name: "All", icon: "fa-solid fa-border-all" },
|
||||||
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" },
|
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
|
||||||
{ id: "tra", name: "Trà", icon: "fa-solid fa-leaf" },
|
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
|
||||||
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
|
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
|
||||||
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
|
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
|
||||||
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
|
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
|
||||||
{
|
{
|
||||||
id: "giai-khat",
|
id: "giai-khat",
|
||||||
name: "Giải Khát / Ăn Vặt",
|
name: "Refreshments / Snacks",
|
||||||
icon: "fa-solid fa-ice-cream",
|
icon: "fa-solid fa-ice-cream",
|
||||||
},
|
},
|
||||||
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
|
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
|
||||||
@@ -57,182 +57,182 @@ export const MENU_CATEGORIES: MenuCategory[] = [
|
|||||||
export const MOCK_PRODUCTS: Product[] = [
|
export const MOCK_PRODUCTS: Product[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Cà Phê Đen",
|
name: "Black Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
price: 25000,
|
price: 25000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
|
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "Cà Phê Sữa",
|
name: "Milk Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
price: 30000,
|
price: 30000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
|
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "Bạc Xỉu",
|
name: "White Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
price: 32000,
|
price: 32000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
|
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
name: "Cà Phê Trứng",
|
name: "Egg Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
price: 45000,
|
price: 45000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
|
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
name: "Trà Đào Cam Sả",
|
name: "Peach Orange Lemongrass Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
price: 35000,
|
price: 35000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
|
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
name: "Trà Xanh Matcha",
|
name: "Matcha Green Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
price: 40000,
|
price: 40000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
|
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
name: "Trà Vải Hoa Nhài",
|
name: "Lychee Jasmine Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
price: 38000,
|
price: 38000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
|
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
name: "Sữa Chua Trân Châu",
|
name: "Yogurt with Tapioca Pearls",
|
||||||
category: "sua-chua",
|
category: "sua-chua",
|
||||||
price: 38000,
|
price: 38000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
|
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
name: "Sữa Chua Dâu",
|
name: "Strawberry Yogurt",
|
||||||
category: "sua-chua",
|
category: "sua-chua",
|
||||||
price: 40000,
|
price: 40000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
|
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
name: "Nước Ép Cam",
|
name: "Fresh Orange Juice",
|
||||||
category: "nuoc-ep",
|
category: "nuoc-ep",
|
||||||
price: 35000,
|
price: 35000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
|
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 11,
|
id: 11,
|
||||||
name: "Nước Ép Dưa Hấu",
|
name: "Watermelon Juice",
|
||||||
category: "nuoc-ep",
|
category: "nuoc-ep",
|
||||||
price: 30000,
|
price: 30000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
|
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
name: "Latte Caramel",
|
name: "Caramel Latte",
|
||||||
category: "latte",
|
category: "latte",
|
||||||
price: 45000,
|
price: 45000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
|
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 13,
|
id: 13,
|
||||||
name: "Latte Vanilla",
|
name: "Vanilla Latte",
|
||||||
category: "latte",
|
category: "latte",
|
||||||
price: 45000,
|
price: 45000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
|
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 14,
|
id: 14,
|
||||||
name: "Bánh Mì Nướng Bơ",
|
name: "Toasted Butter Bread",
|
||||||
category: "giai-khat",
|
category: "giai-khat",
|
||||||
price: 20000,
|
price: 20000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
|
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 15,
|
id: 15,
|
||||||
name: "Bánh Flan",
|
name: "Caramel Flan",
|
||||||
category: "giai-khat",
|
category: "giai-khat",
|
||||||
price: 25000,
|
price: 25000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
|
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 16,
|
id: 16,
|
||||||
name: "Trân Châu Đen",
|
name: "Black Tapioca Pearls",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
price: 10000,
|
price: 10000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 17,
|
id: 17,
|
||||||
name: "Thạch Cà Phê",
|
name: "Coffee Jelly",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
price: 10000,
|
price: 10000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
|
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 18,
|
id: 18,
|
||||||
name: "Trân Châu Trắng",
|
name: "White Tapioca Pearls",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
price: 10000,
|
price: 10000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
description:
|
description:
|
||||||
"Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -241,8 +241,8 @@ export const MOCK_PRODUCTS: Product[] = [
|
|||||||
export const MOCK_COMBOS: Combo[] = [
|
export const MOCK_COMBOS: Combo[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Combo Cà Phê Đôi",
|
name: "Double Coffee Combo",
|
||||||
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
|
description: "2 black coffees + 2 toasted butter breads, save 15%.",
|
||||||
price: 75000,
|
price: 75000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
items: [
|
items: [
|
||||||
@@ -253,8 +253,8 @@ export const MOCK_COMBOS: Combo[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "Combo Trà Sữa Nhóm",
|
name: "Group Tea Combo",
|
||||||
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
|
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
|
||||||
price: 130000,
|
price: 130000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
items: [
|
items: [
|
||||||
@@ -265,8 +265,8 @@ export const MOCK_COMBOS: Combo[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "Combo Buổi Sáng",
|
name: "Morning Combo",
|
||||||
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
|
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
|
||||||
price: 48000,
|
price: 48000,
|
||||||
image: "/imgs/products/placeholder.jpg",
|
image: "/imgs/products/placeholder.jpg",
|
||||||
items: [
|
items: [
|
||||||
@@ -281,6 +281,7 @@ export const MOCK_COMBOS: Combo[] = [
|
|||||||
export const MOCK_SHOPS: Shop[] = [
|
export const MOCK_SHOPS: Shop[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
eateryId: "f39b2da0-aa73-4939-8601-d87b53fe7e27",
|
||||||
name: "The Coffee House",
|
name: "The Coffee House",
|
||||||
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
|
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
|
||||||
image:
|
image:
|
||||||
@@ -288,6 +289,7 @@ export const MOCK_SHOPS: Shop[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
eateryId: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||||
name: "Highlands Coffee",
|
name: "Highlands Coffee",
|
||||||
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
||||||
image:
|
image:
|
||||||
@@ -295,6 +297,7 @@ export const MOCK_SHOPS: Shop[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
|
eateryId: "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
||||||
name: "Phúc Long Heritage",
|
name: "Phúc Long Heritage",
|
||||||
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
|
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
|
||||||
image:
|
image:
|
||||||
@@ -302,6 +305,7 @@ export const MOCK_SHOPS: Shop[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
|
eateryId: "d4e5f6a7-b8c9-0123-def0-234567890123",
|
||||||
name: "Katinat Saigon Kafe",
|
name: "Katinat Saigon Kafe",
|
||||||
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
|
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
|
||||||
image:
|
image:
|
||||||
@@ -309,6 +313,7 @@ export const MOCK_SHOPS: Shop[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
|
eateryId: "e5f6a7b8-c9d0-1234-ef01-345678901234",
|
||||||
name: "Trung Nguyên E-Coffee",
|
name: "Trung Nguyên E-Coffee",
|
||||||
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
|
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
|
||||||
image:
|
image:
|
||||||
@@ -354,34 +359,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
|
|||||||
|
|
||||||
// Weekly revenue (last 12 weeks)
|
// Weekly revenue (last 12 weeks)
|
||||||
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
|
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
|
||||||
{ label: "T1/W1", revenue: 8200000, orders: 295 },
|
{ label: "Jan/W1", revenue: 8200000, orders: 295 },
|
||||||
{ label: "T1/W2", revenue: 9450000, orders: 340 },
|
{ label: "Jan/W2", revenue: 9450000, orders: 340 },
|
||||||
{ label: "T1/W3", revenue: 10100000, orders: 362 },
|
{ label: "Jan/W3", revenue: 10100000, orders: 362 },
|
||||||
{ label: "T1/W4", revenue: 8750000, orders: 315 },
|
{ label: "Jan/W4", revenue: 8750000, orders: 315 },
|
||||||
{ label: "T2/W1", revenue: 9200000, orders: 330 },
|
{ label: "Feb/W1", revenue: 9200000, orders: 330 },
|
||||||
{ label: "T2/W2", revenue: 10500000, orders: 378 },
|
{ label: "Feb/W2", revenue: 10500000, orders: 378 },
|
||||||
{ label: "T2/W3", revenue: 11200000, orders: 400 },
|
{ label: "Feb/W3", revenue: 11200000, orders: 400 },
|
||||||
{ label: "T2/W4", revenue: 9800000, orders: 352 },
|
{ label: "Feb/W4", revenue: 9800000, orders: 352 },
|
||||||
{ label: "T3/W1", revenue: 10400000, orders: 374 },
|
{ label: "Mar/W1", revenue: 10400000, orders: 374 },
|
||||||
{ label: "T3/W2", revenue: 11800000, orders: 424 },
|
{ label: "Mar/W2", revenue: 11800000, orders: 424 },
|
||||||
{ label: "T3/W3", revenue: 12500000, orders: 448 },
|
{ label: "Mar/W3", revenue: 12500000, orders: 448 },
|
||||||
{ label: "T3/W4", revenue: 10900000, orders: 392 },
|
{ label: "Mar/W4", revenue: 10900000, orders: 392 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Monthly revenue (last 12 months)
|
// Monthly revenue (last 12 months)
|
||||||
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
|
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
|
||||||
{ label: "T4/2025", revenue: 42000000, orders: 1512 },
|
{ label: "Apr/2025", revenue: 42000000, orders: 1512 },
|
||||||
{ label: "T5/2025", revenue: 45500000, orders: 1638 },
|
{ label: "May/2025", revenue: 45500000, orders: 1638 },
|
||||||
{ label: "T6/2025", revenue: 48000000, orders: 1728 },
|
{ label: "Jun/2025", revenue: 48000000, orders: 1728 },
|
||||||
{ label: "T7/2025", revenue: 52000000, orders: 1872 },
|
{ label: "Jul/2025", revenue: 52000000, orders: 1872 },
|
||||||
{ label: "T8/2025", revenue: 49500000, orders: 1782 },
|
{ label: "Aug/2025", revenue: 49500000, orders: 1782 },
|
||||||
{ label: "T9/2025", revenue: 46800000, orders: 1685 },
|
{ label: "Sep/2025", revenue: 46800000, orders: 1685 },
|
||||||
{ label: "T10/2025", revenue: 51200000, orders: 1843 },
|
{ label: "Oct/2025", revenue: 51200000, orders: 1843 },
|
||||||
{ label: "T11/2025", revenue: 55000000, orders: 1980 },
|
{ label: "Nov/2025", revenue: 55000000, orders: 1980 },
|
||||||
{ label: "T12/2025", revenue: 62000000, orders: 2232 },
|
{ label: "Dec/2025", revenue: 62000000, orders: 2232 },
|
||||||
{ label: "T1/2026", revenue: 44000000, orders: 1584 },
|
{ label: "Jan/2026", revenue: 44000000, orders: 1584 },
|
||||||
{ label: "T2/2026", revenue: 47500000, orders: 1710 },
|
{ label: "Feb/2026", revenue: 47500000, orders: 1710 },
|
||||||
{ label: "T3/2026", revenue: 53500000, orders: 1926 },
|
{ label: "Mar/2026", revenue: 53500000, orders: 1926 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Yearly revenue (last 5 years)
|
// Yearly revenue (last 5 years)
|
||||||
@@ -397,7 +402,7 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
|
|||||||
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||||
{
|
{
|
||||||
productId: 12,
|
productId: 12,
|
||||||
name: "Latte Caramel",
|
name: "Caramel Latte",
|
||||||
category: "latte",
|
category: "latte",
|
||||||
unitsSold: 487,
|
unitsSold: 487,
|
||||||
revenue: 21915000,
|
revenue: 21915000,
|
||||||
@@ -408,7 +413,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 6,
|
productId: 6,
|
||||||
name: "Trà Xanh Matcha",
|
name: "Matcha Green Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
unitsSold: 412,
|
unitsSold: 412,
|
||||||
revenue: 16480000,
|
revenue: 16480000,
|
||||||
@@ -419,7 +424,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 5,
|
productId: 5,
|
||||||
name: "Trà Đào Cam Sả",
|
name: "Peach Orange Lemongrass Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
unitsSold: 398,
|
unitsSold: 398,
|
||||||
revenue: 13930000,
|
revenue: 13930000,
|
||||||
@@ -430,7 +435,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 13,
|
productId: 13,
|
||||||
name: "Latte Vanilla",
|
name: "Vanilla Latte",
|
||||||
category: "latte",
|
category: "latte",
|
||||||
unitsSold: 356,
|
unitsSold: 356,
|
||||||
revenue: 16020000,
|
revenue: 16020000,
|
||||||
@@ -441,7 +446,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 4,
|
productId: 4,
|
||||||
name: "Cà Phê Trứng",
|
name: "Egg Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
unitsSold: 340,
|
unitsSold: 340,
|
||||||
revenue: 15300000,
|
revenue: 15300000,
|
||||||
@@ -452,7 +457,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 2,
|
productId: 2,
|
||||||
name: "Cà Phê Sữa",
|
name: "Milk Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
unitsSold: 325,
|
unitsSold: 325,
|
||||||
revenue: 9750000,
|
revenue: 9750000,
|
||||||
@@ -463,7 +468,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 3,
|
productId: 3,
|
||||||
name: "Bạc Xỉu",
|
name: "White Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
unitsSold: 298,
|
unitsSold: 298,
|
||||||
revenue: 9536000,
|
revenue: 9536000,
|
||||||
@@ -474,7 +479,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 1,
|
productId: 1,
|
||||||
name: "Cà Phê Đen",
|
name: "Black Coffee",
|
||||||
category: "cafe",
|
category: "cafe",
|
||||||
unitsSold: 285,
|
unitsSold: 285,
|
||||||
revenue: 7125000,
|
revenue: 7125000,
|
||||||
@@ -485,7 +490,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 9,
|
productId: 9,
|
||||||
name: "Sữa Chua Dâu",
|
name: "Strawberry Yogurt",
|
||||||
category: "sua-chua",
|
category: "sua-chua",
|
||||||
unitsSold: 267,
|
unitsSold: 267,
|
||||||
revenue: 10680000,
|
revenue: 10680000,
|
||||||
@@ -496,7 +501,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 10,
|
productId: 10,
|
||||||
name: "Nước Ép Cam",
|
name: "Fresh Orange Juice",
|
||||||
category: "nuoc-ep",
|
category: "nuoc-ep",
|
||||||
unitsSold: 241,
|
unitsSold: 241,
|
||||||
revenue: 8435000,
|
revenue: 8435000,
|
||||||
@@ -507,7 +512,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 8,
|
productId: 8,
|
||||||
name: "Sữa Chua Trân Châu",
|
name: "Yogurt with Tapioca Pearls",
|
||||||
category: "sua-chua",
|
category: "sua-chua",
|
||||||
unitsSold: 228,
|
unitsSold: 228,
|
||||||
revenue: 8664000,
|
revenue: 8664000,
|
||||||
@@ -518,7 +523,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 7,
|
productId: 7,
|
||||||
name: "Trà Vải Hoa Nhài",
|
name: "Lychee Jasmine Tea",
|
||||||
category: "tra",
|
category: "tra",
|
||||||
unitsSold: 215,
|
unitsSold: 215,
|
||||||
revenue: 8170000,
|
revenue: 8170000,
|
||||||
@@ -529,7 +534,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 15,
|
productId: 15,
|
||||||
name: "Bánh Flan",
|
name: "Caramel Flan",
|
||||||
category: "giai-khat",
|
category: "giai-khat",
|
||||||
unitsSold: 198,
|
unitsSold: 198,
|
||||||
revenue: 4950000,
|
revenue: 4950000,
|
||||||
@@ -540,7 +545,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 11,
|
productId: 11,
|
||||||
name: "Nước Ép Dưa Hấu",
|
name: "Watermelon Juice",
|
||||||
category: "nuoc-ep",
|
category: "nuoc-ep",
|
||||||
unitsSold: 182,
|
unitsSold: 182,
|
||||||
revenue: 5460000,
|
revenue: 5460000,
|
||||||
@@ -551,7 +556,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 14,
|
productId: 14,
|
||||||
name: "Bánh Mì Nướng Bơ",
|
name: "Toasted Butter Bread",
|
||||||
category: "giai-khat",
|
category: "giai-khat",
|
||||||
unitsSold: 175,
|
unitsSold: 175,
|
||||||
revenue: 3500000,
|
revenue: 3500000,
|
||||||
@@ -562,7 +567,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 16,
|
productId: 16,
|
||||||
name: "Trân Châu Đen",
|
name: "Black Tapioca Pearls",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
unitsSold: 456,
|
unitsSold: 456,
|
||||||
revenue: 4560000,
|
revenue: 4560000,
|
||||||
@@ -573,7 +578,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 17,
|
productId: 17,
|
||||||
name: "Thạch Cà Phê",
|
name: "Coffee Jelly",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
unitsSold: 389,
|
unitsSold: 389,
|
||||||
revenue: 3890000,
|
revenue: 3890000,
|
||||||
@@ -584,7 +589,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 18,
|
productId: 18,
|
||||||
name: "Trân Châu Trắng",
|
name: "White Tapioca Pearls",
|
||||||
category: "topping",
|
category: "topping",
|
||||||
unitsSold: 342,
|
unitsSold: 342,
|
||||||
revenue: 3420000,
|
revenue: 3420000,
|
||||||
@@ -595,22 +600,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 =====
|
// ===== SHIFT / SCHEDULE DATA =====
|
||||||
|
|
||||||
export const DEPARTMENTS: Department[] = [
|
export const DEPARTMENTS: Department[] = [
|
||||||
@@ -635,9 +624,9 @@ function generateMockShifts(): ShiftSlot[] {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const staffPool = [
|
const staffPool = [
|
||||||
{ id: 2, name: "Nguyễn Văn An" },
|
{ id: 2, name: "Alex Nguyen" },
|
||||||
{ id: 3, name: "Trần Thị Bình" },
|
{ id: 3, name: "Binh Tran" },
|
||||||
{ id: 4, name: "Lê Văn Cường" },
|
{ id: 4, name: "Cuong Le" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Generate shifts from April 6 to April 26, 2026
|
// Generate shifts from April 6 to April 26, 2026
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Eatery, MenuItem } from "./types";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
const message =
|
||||||
|
Array.isArray(body) && body[0]?.message
|
||||||
|
? body[0].message
|
||||||
|
: `GraphQL request failed: ${res.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMenuItemsByEatery(
|
||||||
|
eateryId: string,
|
||||||
|
): Promise<MenuItem[]> {
|
||||||
|
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||||
|
`query menuItemsByEatery($eateryId: String!) {
|
||||||
|
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||||
|
}`,
|
||||||
|
{ eateryId },
|
||||||
|
);
|
||||||
|
return data.menuItemsByEatery ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMenuItem(input: {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}): Promise<MenuItem | null> {
|
||||||
|
const data = await gqlFetch<{ addMenuItem: MenuItem | null }>(
|
||||||
|
`mutation addMenuItem($menuItem: AddMenuItemInput!) {
|
||||||
|
addMenuItem(menuItem: $menuItem) { id name price }
|
||||||
|
}`,
|
||||||
|
{ menuItem: input },
|
||||||
|
);
|
||||||
|
return data.addMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMenuItem(input: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
price?: number;
|
||||||
|
}): Promise<MenuItem | null> {
|
||||||
|
const data = await gqlFetch<{ updateMenuItem: MenuItem | null }>(
|
||||||
|
`mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
|
||||||
|
updateMenuItem(menuItem: $menuItem) { id name price }
|
||||||
|
}`,
|
||||||
|
{ menuItem: input },
|
||||||
|
);
|
||||||
|
return data.updateMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMenuItem(menuItemId: string): Promise<boolean> {
|
||||||
|
const data = await gqlFetch<{ deleteMenuItem: boolean }>(
|
||||||
|
`mutation deleteMenuItem($menuItemId: String!) {
|
||||||
|
deleteMenuItem(menuItemId: $menuItemId)
|
||||||
|
}`,
|
||||||
|
{ menuItemId },
|
||||||
|
);
|
||||||
|
return data.deleteMenuItem ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchManagerEatery(): Promise<Eatery | null> {
|
||||||
|
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
|
||||||
|
query {
|
||||||
|
eateriesByOwner {
|
||||||
|
id
|
||||||
|
ownerId
|
||||||
|
name
|
||||||
|
isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
return data.eateriesByOwner;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { GqlCart } from "./cart.types";
|
||||||
|
|
||||||
|
async function cartFetch<T>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch("/api/cart/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
const message =
|
||||||
|
Array.isArray(body) && body[0]?.message
|
||||||
|
? (body[0].message as string)
|
||||||
|
: `Cart API error: ${res.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCart(eateryId: string): Promise<string> {
|
||||||
|
const data = await cartFetch<{ createCart: string }>(
|
||||||
|
`mutation createCart($eateryId: String) {
|
||||||
|
createCart(eateryId: $eateryId)
|
||||||
|
}`,
|
||||||
|
{ eateryId },
|
||||||
|
);
|
||||||
|
return data.createCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCart(cartId: string): Promise<GqlCart> {
|
||||||
|
const data = await cartFetch<{ getCart: GqlCart }>(
|
||||||
|
`query getCart($cartId: String) {
|
||||||
|
getCart(cartId: $cartId) {
|
||||||
|
id
|
||||||
|
userId
|
||||||
|
eateryId
|
||||||
|
items { productId quantity }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ cartId },
|
||||||
|
);
|
||||||
|
return data.getCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// quantity > 0: add; quantity < 0: decrease/remove (backend uses Redis hincrby)
|
||||||
|
export async function addItem(
|
||||||
|
cartId: string,
|
||||||
|
menuItemId: string,
|
||||||
|
quantity: number,
|
||||||
|
): Promise<GqlCart> {
|
||||||
|
const data = await cartFetch<{ addItem: GqlCart }>(
|
||||||
|
`mutation addItem($cartId: String, $menuItemId: String, $quantity: BigInteger) {
|
||||||
|
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
|
||||||
|
id
|
||||||
|
userId
|
||||||
|
eateryId
|
||||||
|
items { productId quantity }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ cartId, menuItemId, quantity },
|
||||||
|
);
|
||||||
|
return data.addItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend has no removeItem — zero out via negative addItem
|
||||||
|
export async function removeItem(
|
||||||
|
cartId: string,
|
||||||
|
menuItemId: string,
|
||||||
|
currentQuantity: number,
|
||||||
|
): Promise<GqlCart> {
|
||||||
|
return addItem(cartId, menuItemId, -currentQuantity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface GqlCartItem {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GqlCart {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
eateryId: string;
|
||||||
|
items: GqlCartItem[];
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import type { Combo, ComboItem, MenuCategory, Product } from "./types";
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ManagerTab = "products" | "combos" | "categories";
|
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
|
||||||
|
|
||||||
interface ManagerContextType {
|
interface ManagerContextType {
|
||||||
// Data
|
// Data
|
||||||
|
|||||||
@@ -204,14 +204,14 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
staffName: string,
|
staffName: string,
|
||||||
): { success: boolean; error?: string } => {
|
): { success: boolean; error?: string } => {
|
||||||
const shift = shifts.find((s) => s.id === shiftId);
|
const shift = shifts.find((s) => s.id === shiftId);
|
||||||
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
if (!shift) return { success: false, error: "Shift does not exist." };
|
||||||
|
|
||||||
if (shift.registeredStaff.length >= shift.maxStaff) {
|
if (shift.registeredStaff.length >= shift.maxStaff) {
|
||||||
return { success: false, error: "Ca làm đã đủ người." };
|
return { success: false, error: "Shift is already full." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
||||||
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
return { success: false, error: "You have already registered for this shift." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -225,7 +225,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
|
error: "Schedule conflict! You already have an overlapping shift on this day.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface Product {
|
|||||||
image: string;
|
image: string;
|
||||||
description: string;
|
description: string;
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
|
menuItemId?: string; // backend UUID — present when product comes from eatery-service
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SHOP INFO TYPES =====
|
// ===== SHOP INFO TYPES =====
|
||||||
@@ -52,9 +53,24 @@ export interface SocialLinks {
|
|||||||
website: string;
|
website: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== EATERY (GRAPHQL) TYPES =====
|
||||||
|
export interface Eatery {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
name: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== SHOP (QUÁN NƯỚC) TYPES =====
|
// ===== SHOP (QUÁN NƯỚC) TYPES =====
|
||||||
export interface Shop {
|
export interface Shop {
|
||||||
id: number;
|
id: number;
|
||||||
|
eateryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
|||||||
+6
-9
@@ -20,16 +20,13 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const rewrites = [];
|
const gatewayUrl = "http://localhost:32080";
|
||||||
|
return [
|
||||||
if (process.env.NODE_ENV !== "production") {
|
{
|
||||||
rewrites.push({
|
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: "http://host.docker.internal:32080/api/:path*",
|
destination: `${gatewayUrl}/api/:path*`,
|
||||||
});
|
},
|
||||||
}
|
];
|
||||||
|
|
||||||
return rewrites;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user